main 082e1fce0c80 cached
144 files
255.2 KB
68.6k tokens
250 symbols
1 requests
Download .txt
Showing preview only (291K chars total). Download the full file or copy to clipboard to get everything.
Repository: WebDevSimplified/course-platform
Branch: main
Commit: 082e1fce0c80
Files: 144
Total size: 255.2 KB

Directory structure:
gitextract_7h_8njrb/

├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── docker-compose.yml
├── drizzle.config.ts
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── src/
│   ├── app/
│   │   ├── (auth)/
│   │   │   ├── layout.tsx
│   │   │   ├── sign-in/
│   │   │   │   └── [[...sign-in]]/
│   │   │   │       └── page.tsx
│   │   │   └── sign-up/
│   │   │       └── [[...sign-up]]/
│   │   │           └── page.tsx
│   │   ├── (consumer)/
│   │   │   ├── courses/
│   │   │   │   ├── [courseId]/
│   │   │   │   │   ├── _client.tsx
│   │   │   │   │   ├── layout.tsx
│   │   │   │   │   ├── lessons/
│   │   │   │   │   │   └── [lessonId]/
│   │   │   │   │   │       └── page.tsx
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx
│   │   │   ├── products/
│   │   │   │   ├── [productId]/
│   │   │   │   │   ├── page.tsx
│   │   │   │   │   └── purchase/
│   │   │   │   │       ├── page.tsx
│   │   │   │   │       └── success/
│   │   │   │   │           └── page.tsx
│   │   │   │   └── purchase-failure/
│   │   │   │       └── page.tsx
│   │   │   └── purchases/
│   │   │       ├── [purchaseId]/
│   │   │       │   └── page.tsx
│   │   │       └── page.tsx
│   │   ├── admin/
│   │   │   ├── courses/
│   │   │   │   ├── [courseId]/
│   │   │   │   │   └── edit/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx
│   │   │   ├── products/
│   │   │   │   ├── [productId]/
│   │   │   │   │   └── edit/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   └── sales/
│   │   │       └── page.tsx
│   │   ├── api/
│   │   │   ├── clerk/
│   │   │   │   └── syncUsers/
│   │   │   │       └── route.ts
│   │   │   └── webhooks/
│   │   │       ├── clerk/
│   │   │       │   └── route.ts
│   │   │       └── stripe/
│   │   │           └── route.ts
│   │   ├── globals.css
│   │   └── layout.tsx
│   ├── components/
│   │   ├── ActionButton.tsx
│   │   ├── LoadingSpinner.tsx
│   │   ├── PageHeader.tsx
│   │   ├── RequiredLabelIcon.tsx
│   │   ├── Skeleton.tsx
│   │   ├── SortableList.tsx
│   │   └── ui/
│   │       ├── accordion.tsx
│   │       ├── alert-dialog.tsx
│   │       ├── badge.tsx
│   │       ├── button.tsx
│   │       ├── card.tsx
│   │       ├── command.tsx
│   │       ├── custom/
│   │       │   └── multi-select.tsx
│   │       ├── dialog.tsx
│   │       ├── form.tsx
│   │       ├── input.tsx
│   │       ├── label.tsx
│   │       ├── popover.tsx
│   │       ├── select.tsx
│   │       ├── table.tsx
│   │       ├── tabs.tsx
│   │       ├── textarea.tsx
│   │       ├── toast.tsx
│   │       └── toaster.tsx
│   ├── data/
│   │   ├── env/
│   │   │   ├── client.ts
│   │   │   └── server.ts
│   │   ├── pppCoupons.ts
│   │   └── typeOverrides/
│   │       └── clerk.d.ts
│   ├── drizzle/
│   │   ├── db.ts
│   │   ├── migrations/
│   │   │   ├── 0000_orange_wind_dancer.sql
│   │   │   └── meta/
│   │   │       ├── 0000_snapshot.json
│   │   │       └── _journal.json
│   │   ├── schema/
│   │   │   ├── course.ts
│   │   │   ├── courseProduct.ts
│   │   │   ├── courseSection.ts
│   │   │   ├── lesson.ts
│   │   │   ├── product.ts
│   │   │   ├── purchase.ts
│   │   │   ├── user.ts
│   │   │   ├── userCourseAccess.ts
│   │   │   └── userLessonComplete.ts
│   │   ├── schema.ts
│   │   └── schemaHelpers.ts
│   ├── features/
│   │   ├── courseSections/
│   │   │   ├── actions/
│   │   │   │   └── sections.ts
│   │   │   ├── components/
│   │   │   │   ├── SectionForm.tsx
│   │   │   │   ├── SectionFormDialog.tsx
│   │   │   │   └── SortableSectionList.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── sections.ts
│   │   │   ├── permissions/
│   │   │   │   └── sections.ts
│   │   │   └── schemas/
│   │   │       └── sections.ts
│   │   ├── courses/
│   │   │   ├── actions/
│   │   │   │   └── courses.ts
│   │   │   ├── components/
│   │   │   │   ├── CourseForm.tsx
│   │   │   │   └── CourseTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache/
│   │   │   │   │   ├── courses.ts
│   │   │   │   │   └── userCourseAccess.ts
│   │   │   │   ├── courses.ts
│   │   │   │   └── userCourseAcccess.ts
│   │   │   ├── permissions/
│   │   │   │   └── courses.ts
│   │   │   └── schemas/
│   │   │       └── courses.ts
│   │   ├── lessons/
│   │   │   ├── actions/
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   ├── components/
│   │   │   │   ├── LessonForm.tsx
│   │   │   │   ├── LessonFormDialog.tsx
│   │   │   │   ├── SortableLessonList.tsx
│   │   │   │   └── YouTubeVideoPlayer.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache/
│   │   │   │   │   ├── lessons.ts
│   │   │   │   │   └── userLessonComplete.ts
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   ├── permissions/
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   └── schemas/
│   │   │       └── lessons.ts
│   │   ├── products/
│   │   │   ├── actions/
│   │   │   │   └── products.ts
│   │   │   ├── components/
│   │   │   │   ├── ProductCard.tsx
│   │   │   │   ├── ProductForm.tsx
│   │   │   │   └── ProductTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── products.ts
│   │   │   ├── permissions/
│   │   │   │   └── products.ts
│   │   │   └── schema/
│   │   │       └── products.ts
│   │   ├── purchases/
│   │   │   ├── actions/
│   │   │   │   └── purchases.ts
│   │   │   ├── components/
│   │   │   │   ├── PurchaseTable.tsx
│   │   │   │   └── UserPurchaseTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── purchases.ts
│   │   │   └── permissions/
│   │   │       └── products.ts
│   │   └── users/
│   │       └── db/
│   │           ├── cache.ts
│   │           └── users.ts
│   ├── hooks/
│   │   └── use-toast.ts
│   ├── lib/
│   │   ├── dataCache.ts
│   │   ├── formatters.ts
│   │   ├── sumArray.ts
│   │   ├── userCountryHeader.ts
│   │   └── utils.ts
│   ├── middleware.ts
│   ├── permissions/
│   │   └── general.ts
│   └── services/
│       ├── clerk.ts
│       └── stripe/
│           ├── actions/
│           │   └── stripe.ts
│           ├── components/
│           │   └── StripeCheckoutForm.tsx
│           ├── stripeClient.ts
│           └── stripeServer.ts
├── tailwind.config.ts
└── tsconfig.json

================================================
FILE CONTENTS
================================================

================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*

# env files (can opt-in for committing if needed)
.env*

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2025 WebDevSimplified

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.


================================================
FILE: components.json
================================================
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/app/globals.css",
    "baseColor": "zinc",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}

================================================
FILE: docker-compose.yml
================================================
services:
  db:
    image: postgres:17.0
    hostname: localhost
    ports:
      - "5432:5432"
    environment:
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_USER=${DB_USER}
      - POSTGRES_DB=${DB_NAME}
    volumes:
      - pgdata:/var/lib/postgresql/data
volumes:
  pgdata:


================================================
FILE: drizzle.config.ts
================================================
import { env } from "@/data/env/server"
import { defineConfig } from "drizzle-kit"

export default defineConfig({
  out: "./src/drizzle/migrations",
  schema: "./src/drizzle/schema.ts",
  dialect: "postgresql",
  strict: true,
  verbose: true,
  dbCredentials: {
    password: env.DB_PASSWORD,
    user: env.DB_USER,
    database: env.DB_NAME,
    host: env.DB_HOST,
    ssl: false,
  },
})


================================================
FILE: eslint.config.mjs
================================================
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const compat = new FlatCompat({
  baseDirectory: __dirname,
});

const eslintConfig = [
  ...compat.extends("next/core-web-vitals", "next/typescript"),
];

export default eslintConfig;


================================================
FILE: next.config.ts
================================================
import type { NextConfig } from "next"

const nextConfig: NextConfig = {
  /* config options here */
  experimental: {
    dynamicIO: true,
    authInterrupts: true,
  },
}

export default nextConfig


================================================
FILE: package.json
================================================
{
  "name": "course-platform-project",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "db:generate": "drizzle-kit generate",
    "db:migrate": "drizzle-kit migrate",
    "db:studio": "drizzle-kit studio"
  },
  "dependencies": {
    "@arcjet/next": "^1.0.0-beta.1",
    "@clerk/nextjs": "^6.9.10",
    "@dnd-kit/core": "^6.3.1",
    "@dnd-kit/sortable": "^10.0.0",
    "@hookform/resolvers": "^3.10.0",
    "@radix-ui/react-accordion": "^1.2.2",
    "@radix-ui/react-alert-dialog": "^1.1.4",
    "@radix-ui/react-dialog": "^1.1.4",
    "@radix-ui/react-label": "^2.1.1",
    "@radix-ui/react-popover": "^1.1.4",
    "@radix-ui/react-select": "^2.1.4",
    "@radix-ui/react-slot": "^1.1.1",
    "@radix-ui/react-tabs": "^1.1.2",
    "@radix-ui/react-toast": "^1.2.4",
    "@stripe/react-stripe-js": "^3.1.1",
    "@stripe/stripe-js": "^5.5.0",
    "@t3-oss/env-nextjs": "^0.11.1",
    "class-variance-authority": "^0.7.1",
    "clsx": "^2.1.1",
    "cmdk": "^1.0.0",
    "drizzle-orm": "^0.38.3",
    "lucide-react": "^0.471.1",
    "next": "^15.2.0-canary.12",
    "pg": "^8.13.1",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "react-hook-form": "^7.54.2",
    "react-youtube": "^10.1.0",
    "stripe": "^17.5.0",
    "svix": "^1.45.1",
    "tailwind-merge": "^2.6.0",
    "tailwindcss-animate": "^1.0.7",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3",
    "@tailwindcss/container-queries": "^0.1.1",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "drizzle-kit": "^0.30.1",
    "eslint": "^9",
    "eslint-config-next": "15.2.0-canary.11",
    "postcss": "^8",
    "tailwindcss": "^3.4.1",
    "typescript": "^5"
  }
}


================================================
FILE: postcss.config.mjs
================================================
/** @type {import('postcss-load-config').Config} */
const config = {
  plugins: {
    tailwindcss: {},
  },
};

export default config;


================================================
FILE: src/app/(auth)/layout.tsx
================================================
export default async function AuthLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="min-h-screen flex flex-col justify-center items-center">
      {children}
    </div>
  )
}


================================================
FILE: src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
================================================
import { SignIn } from "@clerk/nextjs"

export default function Page() {
  return <SignIn />
}


================================================
FILE: src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
================================================
import { SignUp } from "@clerk/nextjs"

export default function Page() {
  return <SignUp />
}


================================================
FILE: src/app/(consumer)/courses/[courseId]/_client.tsx
================================================
"use client"

import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { CheckCircle2Icon, VideoIcon } from "lucide-react"
import Link from "next/link"
import { useParams } from "next/navigation"

export function CoursePageClient({
  course,
}: {
  course: {
    id: string
    courseSections: {
      id: string
      name: string
      lessons: {
        id: string
        name: string
        isComplete: boolean
      }[]
    }[]
  }
}) {
  const { lessonId } = useParams()
  const defaultValue =
    typeof lessonId === "string"
      ? course.courseSections.find(section =>
          section.lessons.find(lesson => lesson.id === lessonId)
        )
      : course.courseSections[0]

  return (
    <Accordion
      type="multiple"
      defaultValue={defaultValue ? [defaultValue.id] : undefined}
    >
      {course.courseSections.map(section => (
        <AccordionItem key={section.id} value={section.id}>
          <AccordionTrigger className="text-lg">
            {section.name}
          </AccordionTrigger>
          <AccordionContent className="flex flex-col gap-1">
            {section.lessons.map(lesson => (
              <Button
                variant="ghost"
                asChild
                key={lesson.id}
                className={cn(
                  "justify-start",
                  lesson.id === lessonId &&
                    "bg-accent/75 text-accent-foreground"
                )}
              >
                <Link href={`/courses/${course.id}/lessons/${lesson.id}`}>
                  <VideoIcon />
                  {lesson.name}
                  {lesson.isComplete && (
                    <CheckCircle2Icon className="ml-auto" />
                  )}
                </Link>
              </Button>
            ))}
          </AccordionContent>
        </AccordionItem>
      ))}
    </Accordion>
  )
}


================================================
FILE: src/app/(consumer)/courses/[courseId]/layout.tsx
================================================
import { db } from "@/drizzle/db"
import {
  CourseSectionTable,
  CourseTable,
  LessonTable,
  UserLessonCompleteTable,
} from "@/drizzle/schema"
import { getCourseIdTag } from "@/features/courses/db/cache/courses"
import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons"
import { wherePublicLessons } from "@/features/lessons/permissions/lessons"
import { getCurrentUser } from "@/services/clerk"
import { asc, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { notFound } from "next/navigation"
import { ReactNode, Suspense } from "react"
import { CoursePageClient } from "./_client"
import { getUserLessonCompleteUserTag } from "@/features/lessons/db/cache/userLessonComplete"

export default async function CoursePageLayout({
  params,
  children,
}: {
  params: Promise<{ courseId: string }>
  children: ReactNode
}) {
  const { courseId } = await params
  const course = await getCourse(courseId)

  if (course == null) return notFound()

  return (
    <div className="grid grid-cols-[300px,1fr] gap-8 container">
      <div className="py-4">
        <div className="text-lg font-semibold">{course.name}</div>
        <Suspense
          fallback={<CoursePageClient course={mapCourse(course, [])} />}
        >
          <SuspenseBoundary course={course} />
        </Suspense>
      </div>
      <div className="py-4">{children}</div>
    </div>
  )
}

async function getCourse(id: string) {
  "use cache"
  cacheTag(
    getCourseIdTag(id),
    getCourseSectionCourseTag(id),
    getLessonCourseTag(id)
  )

  return db.query.CourseTable.findFirst({
    where: eq(CourseTable.id, id),
    columns: { id: true, name: true },
    with: {
      courseSections: {
        orderBy: asc(CourseSectionTable.order),
        where: wherePublicCourseSections,
        columns: { id: true, name: true },
        with: {
          lessons: {
            orderBy: asc(LessonTable.order),
            where: wherePublicLessons,
            columns: {
              id: true,
              name: true,
            },
          },
        },
      },
    },
  })
}

async function SuspenseBoundary({
  course,
}: {
  course: {
    name: string
    id: string
    courseSections: {
      name: string
      id: string
      lessons: {
        name: string
        id: string
      }[]
    }[]
  }
}) {
  const { userId } = await getCurrentUser()
  const completedLessonIds =
    userId == null ? [] : await getCompletedLessonIds(userId)

  return <CoursePageClient course={mapCourse(course, completedLessonIds)} />
}

async function getCompletedLessonIds(userId: string) {
  "use cache"
  cacheTag(getUserLessonCompleteUserTag(userId))

  const data = await db.query.UserLessonCompleteTable.findMany({
    columns: { lessonId: true },
    where: eq(UserLessonCompleteTable.userId, userId),
  })

  return data.map(d => d.lessonId)
}

function mapCourse(
  course: {
    name: string
    id: string
    courseSections: {
      name: string
      id: string
      lessons: {
        name: string
        id: string
      }[]
    }[]
  },
  completedLessonIds: string[]
) {
  return {
    ...course,
    courseSections: course.courseSections.map(section => {
      return {
        ...section,
        lessons: section.lessons.map(lesson => {
          return {
            ...lesson,
            isComplete: completedLessonIds.includes(lesson.id),
          }
        }),
      }
    }),
  }
}


================================================
FILE: src/app/(consumer)/courses/[courseId]/lessons/[lessonId]/page.tsx
================================================
import { ActionButton } from "@/components/ActionButton"
import { SkeletonButton } from "@/components/Skeleton"
import { Button } from "@/components/ui/button"
import { db } from "@/drizzle/db"
import {
  CourseSectionTable,
  LessonStatus,
  LessonTable,
  UserLessonCompleteTable,
} from "@/drizzle/schema"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { updateLessonCompleteStatus } from "@/features/lessons/actions/userLessonComplete"
import { YouTubeVideoPlayer } from "@/features/lessons/components/YouTubeVideoPlayer"
import { getLessonIdTag } from "@/features/lessons/db/cache/lessons"
import { getUserLessonCompleteIdTag } from "@/features/lessons/db/cache/userLessonComplete"
import {
  canViewLesson,
  wherePublicLessons,
} from "@/features/lessons/permissions/lessons"
import { canUpdateUserLessonCompleteStatus } from "@/features/lessons/permissions/userLessonComplete"
import { getCurrentUser } from "@/services/clerk"
import { and, asc, desc, eq, gt, lt } from "drizzle-orm"
import { CheckSquare2Icon, LockIcon, XSquareIcon } from "lucide-react"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Link from "next/link"
import { notFound } from "next/navigation"
import { ReactNode, Suspense } from "react"

export default async function LessonPage({
  params,
}: {
  params: Promise<{ courseId: string; lessonId: string }>
}) {
  const { courseId, lessonId } = await params
  const lesson = await getLesson(lessonId)

  if (lesson == null) return notFound()

  return (
    <Suspense fallback={<LoadingSkeleton />}>
      <SuspenseBoundary lesson={lesson} courseId={courseId} />
    </Suspense>
  )
}

function LoadingSkeleton() {
  return null
}

async function SuspenseBoundary({
  lesson,
  courseId,
}: {
  lesson: {
    id: string
    youtubeVideoId: string
    name: string
    description: string | null
    status: LessonStatus
    sectionId: string
    order: number
  }
  courseId: string
}) {
  const { userId, role } = await getCurrentUser()
  const isLessonComplete =
    userId == null
      ? false
      : await getIsLessonComplete({ lessonId: lesson.id, userId })
  const canView = await canViewLesson({ role, userId }, lesson)
  const canUpdateCompletionStatus = await canUpdateUserLessonCompleteStatus(
    { userId },
    lesson.id
  )

  return (
    <div className="my-4 flex flex-col gap-4">
      <div className="aspect-video">
        {canView ? (
          <YouTubeVideoPlayer
            videoId={lesson.youtubeVideoId}
            onFinishedVideo={
              !isLessonComplete && canUpdateCompletionStatus
                ? updateLessonCompleteStatus.bind(null, lesson.id, true)
                : undefined
            }
          />
        ) : (
          <div className="flex items-center justify-center bg-primary text-primary-foreground h-full w-full">
            <LockIcon className="size-16" />
          </div>
        )}
      </div>
      <div className="flex flex-col gap-2">
        <div className="flex justify-between items-start gap-4">
          <h1 className="text-2xl font-semibold">{lesson.name}</h1>
          <div className="flex gap-2 justify-end">
            <Suspense fallback={<SkeletonButton />}>
              <ToLessonButton
                lesson={lesson}
                courseId={courseId}
                lessonFunc={getPreviousLesson}
              >
                Previous
              </ToLessonButton>
            </Suspense>
            {canUpdateCompletionStatus && (
              <ActionButton
                action={updateLessonCompleteStatus.bind(
                  null,
                  lesson.id,
                  !isLessonComplete
                )}
                variant="outline"
              >
                <div className="flex gap-2 items-center">
                  {isLessonComplete ? (
                    <>
                      <CheckSquare2Icon /> Mark Incomplete
                    </>
                  ) : (
                    <>
                      <XSquareIcon /> Mark Complete
                    </>
                  )}
                </div>
              </ActionButton>
            )}
            <Suspense fallback={<SkeletonButton />}>
              <ToLessonButton
                lesson={lesson}
                courseId={courseId}
                lessonFunc={getNextLesson}
              >
                Next
              </ToLessonButton>
            </Suspense>
          </div>
        </div>
        {canView ? (
          lesson.description && <p>{lesson.description}</p>
        ) : (
          <p>This lesson is locked. Please purchase the course to view it.</p>
        )}
      </div>
    </div>
  )
}

async function ToLessonButton({
  children,
  courseId,
  lessonFunc,
  lesson,
}: {
  children: ReactNode
  courseId: string
  lesson: {
    id: string
    sectionId: string
    order: number
  }
  lessonFunc: (lesson: {
    id: string
    sectionId: string
    order: number
  }) => Promise<{ id: string } | undefined>
}) {
  const toLesson = await lessonFunc(lesson)
  if (toLesson == null) return null

  return (
    <Button variant="outline" asChild>
      <Link href={`/courses/${courseId}/lessons/${toLesson.id}`}>
        {children}
      </Link>
    </Button>
  )
}

async function getPreviousLesson(lesson: {
  id: string
  sectionId: string
  order: number
}) {
  let previousLesson = await db.query.LessonTable.findFirst({
    where: and(
      lt(LessonTable.order, lesson.order),
      eq(LessonTable.sectionId, lesson.sectionId),
      wherePublicLessons
    ),
    orderBy: desc(LessonTable.order),
    columns: { id: true },
  })

  if (previousLesson == null) {
    const section = await db.query.CourseSectionTable.findFirst({
      where: eq(CourseSectionTable.id, lesson.sectionId),
      columns: { order: true, courseId: true },
    })

    if (section == null) return

    const previousSection = await db.query.CourseSectionTable.findFirst({
      where: and(
        lt(CourseSectionTable.order, section.order),
        eq(CourseSectionTable.courseId, section.courseId),
        wherePublicCourseSections
      ),
      orderBy: desc(CourseSectionTable.order),
      columns: { id: true },
    })

    if (previousSection == null) return

    previousLesson = await db.query.LessonTable.findFirst({
      where: and(
        eq(LessonTable.sectionId, previousSection.id),
        wherePublicLessons
      ),
      orderBy: desc(LessonTable.order),
      columns: { id: true },
    })
  }

  return previousLesson
}

async function getNextLesson(lesson: {
  id: string
  sectionId: string
  order: number
}) {
  let nextLesson = await db.query.LessonTable.findFirst({
    where: and(
      gt(LessonTable.order, lesson.order),
      eq(LessonTable.sectionId, lesson.sectionId),
      wherePublicLessons
    ),
    orderBy: asc(LessonTable.order),
    columns: { id: true },
  })

  if (nextLesson == null) {
    const section = await db.query.CourseSectionTable.findFirst({
      where: eq(CourseSectionTable.id, lesson.sectionId),
      columns: { order: true, courseId: true },
    })

    if (section == null) return

    const nextSection = await db.query.CourseSectionTable.findFirst({
      where: and(
        gt(CourseSectionTable.order, section.order),
        eq(CourseSectionTable.courseId, section.courseId),
        wherePublicCourseSections
      ),
      orderBy: asc(CourseSectionTable.order),
      columns: { id: true },
    })

    if (nextSection == null) return

    nextLesson = await db.query.LessonTable.findFirst({
      where: and(eq(LessonTable.sectionId, nextSection.id), wherePublicLessons),
      orderBy: asc(LessonTable.order),
      columns: { id: true },
    })
  }

  return nextLesson
}

async function getLesson(id: string) {
  "use cache"
  cacheTag(getLessonIdTag(id))

  return db.query.LessonTable.findFirst({
    columns: {
      id: true,
      youtubeVideoId: true,
      name: true,
      description: true,
      status: true,
      sectionId: true,
      order: true,
    },
    where: and(eq(LessonTable.id, id), wherePublicLessons),
  })
}

async function getIsLessonComplete({
  userId,
  lessonId,
}: {
  userId: string
  lessonId: string
}) {
  "use cache"
  cacheTag(getUserLessonCompleteIdTag({ userId, lessonId }))

  const data = await db.query.UserLessonCompleteTable.findFirst({
    where: and(
      eq(UserLessonCompleteTable.userId, userId),
      eq(UserLessonCompleteTable.lessonId, lessonId)
    ),
  })

  return data != null
}


================================================
FILE: src/app/(consumer)/courses/[courseId]/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { db } from "@/drizzle/db"
import { CourseTable } from "@/drizzle/schema"
import { getCourseIdTag } from "@/features/courses/db/cache/courses"
import { eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { notFound } from "next/navigation"

export default async function CoursePage({
  params,
}: {
  params: Promise<{ courseId: string }>
}) {
  const { courseId } = await params
  const course = await getCourse(courseId)

  if (course == null) return notFound()

  return (
    <div className="my-6 container">
      <PageHeader className="mb-2" title={course.name} />
      <p className="text-muted-foreground">{course.description}</p>
    </div>
  )
}

async function getCourse(id: string) {
  "use cache"
  cacheTag(getCourseIdTag(id))

  return db.query.CourseTable.findFirst({
    columns: { id: true, name: true, description: true },
    where: eq(CourseTable.id, id),
  })
}


================================================
FILE: src/app/(consumer)/courses/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import {
  SkeletonArray,
  SkeletonButton,
  SkeletonText,
} from "@/components/Skeleton"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { db } from "@/drizzle/db"
import {
  CourseSectionTable,
  CourseTable,
  LessonTable,
  UserCourseAccessTable,
  UserLessonCompleteTable,
} from "@/drizzle/schema"
import { getCourseIdTag } from "@/features/courses/db/cache/courses"
import { getUserCourseAccessUserTag } from "@/features/courses/db/cache/userCourseAccess"
import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons"
import { getUserLessonCompleteUserTag } from "@/features/lessons/db/cache/userLessonComplete"
import { wherePublicLessons } from "@/features/lessons/permissions/lessons"
import { formatPlural } from "@/lib/formatters"
import { getCurrentUser } from "@/services/clerk"
import { and, countDistinct, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Link from "next/link"
import { Suspense } from "react"

export default function CoursesPage() {
  return (
    <div className="container my-6">
      <PageHeader title="My Courses" />
      <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
        <Suspense
          fallback={
            <SkeletonArray amount={3}>
              <SkeletonCourseCard />
            </SkeletonArray>
          }
        >
          <CourseGrid />
        </Suspense>
      </div>
    </div>
  )
}

async function CourseGrid() {
  const { userId, redirectToSignIn } = await getCurrentUser()
  if (userId == null) return redirectToSignIn()

  const courses = await getUserCourses(userId)

  if (courses.length === 0) {
    return (
      <div className="flex flex-col gap-2 items-start">
        You have no courses yet
        <Button asChild size="lg">
          <Link href="/">Browse Courses</Link>
        </Button>
      </div>
    )
  }

  return courses.map(course => (
    <Card key={course.id} className="overflow-hidden flex flex-col">
      <CardHeader>
        <CardTitle>{course.name}</CardTitle>
        <CardDescription>
          {formatPlural(course.sectionsCount, {
            plural: "sections",
            singular: "section",
          })}{" "}
          •{" "}
          {formatPlural(course.lessonsCount, {
            plural: "lessons",
            singular: "lesson",
          })}
        </CardDescription>
      </CardHeader>
      <CardContent className="line-clamp-3" title={course.description}>
        {course.description}
      </CardContent>
      <div className="flex-grow" />
      <CardFooter>
        <Button asChild>
          <Link href={`/courses/${course.id}`}>View Course</Link>
        </Button>
      </CardFooter>
      <div
        className="bg-accent h-2 -mt-2"
        style={{
          width: `${(course.lessonsComplete / course.lessonsCount) * 100}%`,
        }}
      />
    </Card>
  ))
}

function SkeletonCourseCard() {
  return (
    <Card>
      <CardHeader>
        <CardTitle>
          <SkeletonText className="w-3/4" />
        </CardTitle>
        <CardDescription>
          <SkeletonText className="w-1/2" />
        </CardDescription>
      </CardHeader>
      <CardContent>
        <SkeletonText rows={3} />
      </CardContent>
      <CardFooter>
        <SkeletonButton />
      </CardFooter>
    </Card>
  )
}

async function getUserCourses(userId: string) {
  "use cache"
  cacheTag(
    getUserCourseAccessUserTag(userId),
    getUserLessonCompleteUserTag(userId)
  )

  const courses = await db
    .select({
      id: CourseTable.id,
      name: CourseTable.name,
      description: CourseTable.description,
      sectionsCount: countDistinct(CourseSectionTable.id),
      lessonsCount: countDistinct(LessonTable.id),
      lessonsComplete: countDistinct(UserLessonCompleteTable.lessonId),
    })
    .from(CourseTable)
    .leftJoin(
      UserCourseAccessTable,
      and(
        eq(UserCourseAccessTable.courseId, CourseTable.id),
        eq(UserCourseAccessTable.userId, userId)
      )
    )
    .leftJoin(
      CourseSectionTable,
      and(
        eq(CourseSectionTable.courseId, CourseTable.id),
        wherePublicCourseSections
      )
    )
    .leftJoin(
      LessonTable,
      and(eq(LessonTable.sectionId, CourseSectionTable.id), wherePublicLessons)
    )
    .leftJoin(
      UserLessonCompleteTable,
      and(
        eq(UserLessonCompleteTable.lessonId, LessonTable.id),
        eq(UserLessonCompleteTable.userId, userId)
      )
    )
    .orderBy(CourseTable.name)
    .groupBy(CourseTable.id)

  courses.forEach(course => {
    cacheTag(
      getCourseIdTag(course.id),
      getCourseSectionCourseTag(course.id),
      getLessonCourseTag(course.id)
    )
  })

  return courses
}


================================================
FILE: src/app/(consumer)/layout.tsx
================================================
import { Button } from "@/components/ui/button"
import { canAccessAdminPages } from "@/permissions/general"
import { getCurrentUser } from "@/services/clerk"
import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs"
import Link from "next/link"
import { ReactNode, Suspense } from "react"

export default function ConsumerLayout({
  children,
}: Readonly<{ children: ReactNode }>) {
  return (
    <>
      <Navbar />
      {children}
    </>
  )
}

function Navbar() {
  return (
    <header className="flex h-12 shadow bg-background z-10">
      <nav className="flex gap-4 container">
        <Link
          className="mr-auto text-lg hover:underline flex items-center"
          href="/"
        >
          Web Dev Simplified
        </Link>
        <Suspense>
          <SignedIn>
            <AdminLink />
            <Link
              className="hover:bg-accent/10 flex items-center px-2"
              href="/courses"
            >
              My Courses
            </Link>
            <Link
              className="hover:bg-accent/10 flex items-center px-2"
              href="/purchases"
            >
              Purchase History
            </Link>
            <div className="size-8 self-center">
              <UserButton
                appearance={{
                  elements: {
                    userButtonAvatarBox: { width: "100%", height: "100%" },
                  },
                }}
              />
            </div>
          </SignedIn>
        </Suspense>
        <Suspense>
          <SignedOut>
            <Button className="self-center" asChild>
              <SignInButton>Sign In</SignInButton>
            </Button>
          </SignedOut>
        </Suspense>
      </nav>
    </header>
  )
}

async function AdminLink() {
  const user = await getCurrentUser()
  if (!canAccessAdminPages(user)) return null

  return (
    <Link className="hover:bg-accent/10 flex items-center px-2" href="/admin">
      Admin
    </Link>
  )
}


================================================
FILE: src/app/(consumer)/page.tsx
================================================
import { db } from "@/drizzle/db"
import { ProductTable } from "@/drizzle/schema"
import { ProductCard } from "@/features/products/components/ProductCard"
import { getProductGlobalTag } from "@/features/products/db/cache"
import { wherePublicProducts } from "@/features/products/permissions/products"
import { asc } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"

export default async function HomePage() {
  const products = await getPublicProducts()

  return (
    <div className="container my-6">
      <div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-4">
        {products.map(product => (
          <ProductCard key={product.id} {...product} />
        ))}
      </div>
    </div>
  )
}

async function getPublicProducts() {
  "use cache"
  cacheTag(getProductGlobalTag())

  return db.query.ProductTable.findMany({
    columns: {
      id: true,
      name: true,
      description: true,
      priceInDollars: true,
      imageUrl: true,
    },
    where: wherePublicProducts,
    orderBy: asc(ProductTable.name),
  })
}


================================================
FILE: src/app/(consumer)/products/[productId]/page.tsx
================================================
import { SkeletonButton } from "@/components/Skeleton"
import {
  Accordion,
  AccordionContent,
  AccordionItem,
  AccordionTrigger,
} from "@/components/ui/accordion"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { db } from "@/drizzle/db"
import { CourseSectionTable, LessonTable, ProductTable } from "@/drizzle/schema"
import { getCourseIdTag } from "@/features/courses/db/cache/courses"
import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache"
import { wherePublicCourseSections } from "@/features/courseSections/permissions/sections"
import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons"
import { wherePublicLessons } from "@/features/lessons/permissions/lessons"
import { getProductIdTag } from "@/features/products/db/cache"
import { userOwnsProduct } from "@/features/products/db/products"
import { wherePublicProducts } from "@/features/products/permissions/products"
import { formatPlural, formatPrice } from "@/lib/formatters"
import { sumArray } from "@/lib/sumArray"
import { getUserCoupon } from "@/lib/userCountryHeader"
import { getCurrentUser } from "@/services/clerk"
import { and, asc, eq } from "drizzle-orm"
import { VideoIcon } from "lucide-react"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Image from "next/image"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Suspense } from "react"

export default async function ProductPage({
  params,
}: {
  params: Promise<{ productId: string }>
}) {
  const { productId } = await params
  const product = await getPublicProduct(productId)

  if (product == null) return notFound()

  const courseCount = product.courses.length
  const lessonCount = sumArray(product.courses, course =>
    sumArray(course.courseSections, s => s.lessons.length)
  )

  return (
    <div className="container my-6">
      <div className="flex gap-16 items-center justify-between">
        <div className="flex gap-6 flex-col items-start">
          <div className="flex flex-col gap-2">
            <Suspense
              fallback={
                <div className="text-xl">
                  {formatPrice(product.priceInDollars)}
                </div>
              }
            >
              <Price price={product.priceInDollars} />
            </Suspense>
            <h1 className="text-4xl font-semibold">{product.name}</h1>
            <div className="text-muted-foreground">
              {formatPlural(courseCount, {
                singular: "course",
                plural: "courses",
              })}{" "}
              •{" "}
              {formatPlural(lessonCount, {
                singular: "lesson",
                plural: "lessons",
              })}
            </div>
          </div>
          <div className="text-xl">{product.description}</div>
          <Suspense fallback={<SkeletonButton className="h-12 w-36" />}>
            <PurchaseButton productId={product.id} />
          </Suspense>
        </div>
        <div className="relative aspect-video max-w-lg flex-grow">
          <Image
            src={product.imageUrl}
            fill
            alt={product.name}
            className="object-contain rounded-xl"
          />
        </div>
      </div>
      <div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mt-8 items-start">
        {product.courses.map(course => (
          <Card key={course.id}>
            <CardHeader>
              <CardTitle>{course.name}</CardTitle>
              <CardDescription>
                {formatPlural(course.courseSections.length, {
                  plural: "sections",
                  singular: "section",
                })}{" "}
                •{" "}
                {formatPlural(
                  sumArray(course.courseSections, s => s.lessons.length),
                  {
                    plural: "lessons",
                    singular: "lesson",
                  }
                )}
              </CardDescription>
            </CardHeader>
            <CardContent>
              <Accordion type="multiple">
                {course.courseSections.map(section => (
                  <AccordionItem key={section.id} value={section.id}>
                    <AccordionTrigger className="flex gap-2">
                      <div className="flex flex-col flex-grow">
                        <span className="text-lg">{section.name}</span>
                        <span className="text-muted-foreground">
                          {formatPlural(section.lessons.length, {
                            plural: "lessons",
                            singular: "lesson",
                          })}
                        </span>
                      </div>
                    </AccordionTrigger>
                    <AccordionContent className="flex flex-col gap-2">
                      {section.lessons.map(lesson => (
                        <div
                          key={lesson.id}
                          className="flex items-center gap-2 text-base"
                        >
                          <VideoIcon className="size-4" />
                          {lesson.status === "preview" ? (
                            <Link
                              href={`/courses/${course.id}/lessons/${lesson.id}`}
                              className="underline text-accent"
                            >
                              {lesson.name}
                            </Link>
                          ) : (
                            lesson.name
                          )}
                        </div>
                      ))}
                    </AccordionContent>
                  </AccordionItem>
                ))}
              </Accordion>
            </CardContent>
          </Card>
        ))}
      </div>
    </div>
  )
}

async function PurchaseButton({ productId }: { productId: string }) {
  const { userId } = await getCurrentUser()
  const alreadyOwnsProduct =
    userId != null && (await userOwnsProduct({ userId, productId }))

  if (alreadyOwnsProduct) {
    return <p>You already own this product</p>
  } else {
    return (
      <Button className="text-xl h-auto py-4 px-8 rounded-lg" asChild>
        <Link href={`/products/${productId}/purchase`}>Get Now</Link>
      </Button>
    )
  }
}

async function Price({ price }: { price: number }) {
  const coupon = await getUserCoupon()
  if (price === 0 || coupon == null) {
    return <div className="text-xl">{formatPrice(price)}</div>
  }

  return (
    <div className="flex gap-2 items-baseline">
      <div className="line-through text-sm opacity-50">
        {formatPrice(price)}
      </div>
      <div className="text-xl">
        {formatPrice(price * (1 - coupon.discountPercentage))}
      </div>
    </div>
  )
}

async function getPublicProduct(id: string) {
  "use cache"
  cacheTag(getProductIdTag(id))

  const product = await db.query.ProductTable.findFirst({
    columns: {
      id: true,
      name: true,
      description: true,
      priceInDollars: true,
      imageUrl: true,
    },
    where: and(eq(ProductTable.id, id), wherePublicProducts),
    with: {
      courseProducts: {
        columns: {},
        with: {
          course: {
            columns: { id: true, name: true },
            with: {
              courseSections: {
                columns: { id: true, name: true },
                where: wherePublicCourseSections,
                orderBy: asc(CourseSectionTable.order),
                with: {
                  lessons: {
                    columns: { id: true, name: true, status: true },
                    where: wherePublicLessons,
                    orderBy: asc(LessonTable.order),
                  },
                },
              },
            },
          },
        },
      },
    },
  })

  if (product == null) return product

  cacheTag(
    ...product.courseProducts.flatMap(cp => [
      getLessonCourseTag(cp.course.id),
      getCourseSectionCourseTag(cp.course.id),
      getCourseIdTag(cp.course.id),
    ])
  )

  const { courseProducts, ...other } = product

  return {
    ...other,
    courses: courseProducts.map(cp => cp.course),
  }
}


================================================
FILE: src/app/(consumer)/products/[productId]/purchase/page.tsx
================================================
import { LoadingSpinner } from "@/components/LoadingSpinner"
import { PageHeader } from "@/components/PageHeader"
import { db } from "@/drizzle/db"
import { ProductTable } from "@/drizzle/schema"
import { getProductIdTag } from "@/features/products/db/cache"
import { userOwnsProduct } from "@/features/products/db/products"
import { wherePublicProducts } from "@/features/products/permissions/products"
import { getCurrentUser } from "@/services/clerk"
import { StripeCheckoutForm } from "@/services/stripe/components/StripeCheckoutForm"
import { SignIn, SignUp } from "@clerk/nextjs"
import { and, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { notFound, redirect } from "next/navigation"
import { Suspense } from "react"

export default function PurchasePage({
  params,
  searchParams,
}: {
  params: Promise<{ productId: string }>
  searchParams: Promise<{ authMode: string }>
}) {
  return (
    <Suspense fallback={<LoadingSpinner className="my-6 size-36 mx-auto" />}>
      <SuspendedComponent params={params} searchParams={searchParams} />
    </Suspense>
  )
}

async function SuspendedComponent({
  params,
  searchParams,
}: {
  params: Promise<{ productId: string }>
  searchParams: Promise<{ authMode: string }>
}) {
  const { productId } = await params
  const { user } = await getCurrentUser({ allData: true })
  const product = await getPublicProduct(productId)

  if (product == null) return notFound()

  if (user != null) {
    if (await userOwnsProduct({ userId: user.id, productId })) {
      redirect("/courses")
    }

    return (
      <div className="container my-6">
        <StripeCheckoutForm product={product} user={user} />
      </div>
    )
  }

  const { authMode } = await searchParams
  const isSignUp = authMode === "signUp"

  return (
    <div className="container my-6 flex flex-col items-center">
      <PageHeader title="You need an account to make a purchase" />
      {isSignUp ? (
        <SignUp
          routing="hash"
          signInUrl={`/products/${productId}/purchase?authMode=signIn`}
          forceRedirectUrl={`/products/${productId}/purchase`}
        />
      ) : (
        <SignIn
          routing="hash"
          signUpUrl={`/products/${productId}/purchase?authMode=signUp`}
          forceRedirectUrl={`/products/${productId}/purchase`}
        />
      )}
    </div>
  )
}

async function getPublicProduct(id: string) {
  "use cache"
  cacheTag(getProductIdTag(id))

  return db.query.ProductTable.findFirst({
    columns: {
      name: true,
      id: true,
      imageUrl: true,
      description: true,
      priceInDollars: true,
    },
    where: and(eq(ProductTable.id, id), wherePublicProducts),
  })
}


================================================
FILE: src/app/(consumer)/products/[productId]/purchase/success/page.tsx
================================================
import { Button } from "@/components/ui/button"
import { db } from "@/drizzle/db"
import { ProductTable } from "@/drizzle/schema"
import { getProductIdTag } from "@/features/products/db/cache"
import { wherePublicProducts } from "@/features/products/permissions/products"
import { and, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Image from "next/image"
import Link from "next/link"

export default async function ProductPurchaseSuccessPage({
  params,
}: {
  params: Promise<{ productId: string }>
}) {
  const { productId } = await params
  const product = await getPublicProduct(productId)

  if (product == null) return

  return (
    <div className="container my-6">
      <div className="flex gap-16 items-center justify-between">
        <div className="flex flex-col gap-4 items-start">
          <div className="text-3xl font-semibold">Purchase Successful</div>
          <div className="text-xl">
            Thank you for purchasing {product.name}.
          </div>
          <Button asChild className="text-xl h-auto py-4 px-8 rounded-lg">
            <Link href="/courses">View My Courses</Link>
          </Button>
        </div>
        <div className="relative aspect-video max-w-lg flex-grow">
          <Image
            src={product.imageUrl}
            alt={product.name}
            fill
            className="object-contain rounded-xl"
          />
        </div>
      </div>
    </div>
  )
}

async function getPublicProduct(id: string) {
  "use cache"
  cacheTag(getProductIdTag(id))

  return db.query.ProductTable.findFirst({
    columns: {
      name: true,
      imageUrl: true,
    },
    where: and(eq(ProductTable.id, id), wherePublicProducts),
  })
}


================================================
FILE: src/app/(consumer)/products/purchase-failure/page.tsx
================================================
import { Button } from "@/components/ui/button"
import Link from "next/link"

export default async function ProductPurchaseFailurePage() {
  return (
    <div className="container my-6">
      <div className="flex flex-col gap-4 items-start">
        <div className="text-3xl font-semibold">Purchase Failed</div>
        <div className="text-xl">
          There was a problem purchasing your product.
        </div>
        <Button asChild className="text-xl h-auto py-4 px-8 rounded-lg">
          <Link href="/">Try again</Link>
        </Button>
      </div>
    </div>
  )
}


================================================
FILE: src/app/(consumer)/purchases/[purchaseId]/page.tsx
================================================
import { LoadingSpinner } from "@/components/LoadingSpinner"
import { PageHeader } from "@/components/PageHeader"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
  Card,
  CardContent,
  CardDescription,
  CardFooter,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { db } from "@/drizzle/db"
import { PurchaseTable } from "@/drizzle/schema"
import { getPurchaseIdTag } from "@/features/purchases/db/cache"
import { formatDate, formatPrice } from "@/lib/formatters"
import { cn } from "@/lib/utils"
import { getCurrentUser } from "@/services/clerk"
import { stripeServerClient } from "@/services/stripe/stripeServer"
import { and, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Link from "next/link"
import { notFound } from "next/navigation"
import { Fragment, Suspense } from "react"
import Stripe from "stripe"

export default async function PurchasePage({
  params,
}: {
  params: Promise<{ purchaseId: string }>
}) {
  const { purchaseId } = await params

  return (
    <div className="container my-6">
      <Suspense fallback={<LoadingSpinner className="size-36 mx-auto" />}>
        <SuspenseBoundary purchaseId={purchaseId} />
      </Suspense>
    </div>
  )
}

async function SuspenseBoundary({ purchaseId }: { purchaseId: string }) {
  const { userId, redirectToSignIn, user } = await getCurrentUser({
    allData: true,
  })
  if (userId == null || user == null) return redirectToSignIn()

  const purchase = await getPurchase({ userId, id: purchaseId })

  if (purchase == null) return notFound()

  const { receiptUrl, pricingRows } = await getStripeDetails(
    purchase.stripeSessionId,
    purchase.pricePaidInCents,
    purchase.refundedAt != null
  )

  return (
    <>
      <PageHeader title={purchase.productDetails.name}>
        {receiptUrl && (
          <Button variant="outline" asChild>
            <Link target="_blank" href={receiptUrl}>
              View Receipt
            </Link>
          </Button>
        )}
      </PageHeader>

      <Card>
        <CardHeader className="pb-4">
          <div className="flex justify-between items-start gap-4">
            <div className="flex flex-col gap-1">
              <CardTitle>Receipt</CardTitle>
              <CardDescription>ID: {purchaseId}</CardDescription>
            </div>
            <Badge className="text-base">
              {purchase.refundedAt ? "Refunded" : "Paid"}
            </Badge>
          </div>
        </CardHeader>
        <CardContent className="pb-4 grid grid-cols-2 gap-8 border-t pt-4">
          <div>
            <label className="text-sm text-muted-foreground">Date</label>
            <div>{formatDate(purchase.createdAt)}</div>
          </div>
          <div>
            <label className="text-sm text-muted-foreground">Product</label>
            <div>{purchase.productDetails.name}</div>
          </div>
          <div>
            <label className="text-sm text-muted-foreground">Customer</label>
            <div>{user.name}</div>
          </div>
          <div>
            <label className="text-sm text-muted-foreground">Seller</label>
            <div>Web Dev Simplified</div>
          </div>
        </CardContent>
        <CardFooter className="grid grid-cols-2 gap-y-4 gap-x-8 border-t pt-4">
          {pricingRows.map(({ label, amountInDollars, isBold }) => (
            <Fragment key={label}>
              <div className={cn(isBold && "font-bold")}>{label}</div>
              <div className={cn("justify-self-end", isBold && "font-bold")}>
                {formatPrice(amountInDollars, { showZeroAsNumber: true })}
              </div>
            </Fragment>
          ))}
        </CardFooter>
      </Card>
    </>
  )
}

async function getPurchase({ userId, id }: { userId: string; id: string }) {
  "use cache"
  cacheTag(getPurchaseIdTag(id))

  return db.query.PurchaseTable.findFirst({
    columns: {
      pricePaidInCents: true,
      refundedAt: true,
      productDetails: true,
      createdAt: true,
      stripeSessionId: true,
    },
    where: and(eq(PurchaseTable.id, id), eq(PurchaseTable.userId, userId)),
  })
}

async function getStripeDetails(
  stripeSessionId: string,
  pricePaidInCents: number,
  isRefunded: boolean
) {
  const { payment_intent, total_details, amount_total, amount_subtotal } =
    await stripeServerClient.checkout.sessions.retrieve(stripeSessionId, {
      expand: [
        "payment_intent.latest_charge",
        "total_details.breakdown.discounts",
      ],
    })

  const refundAmount =
    typeof payment_intent !== "string" &&
    typeof payment_intent?.latest_charge !== "string"
      ? payment_intent?.latest_charge?.amount_refunded
      : isRefunded
      ? pricePaidInCents
      : undefined

  return {
    receiptUrl: getReceiptUrl(payment_intent),
    pricingRows: getPricingRows(total_details, {
      total: (amount_total ?? pricePaidInCents) - (refundAmount ?? 0),
      subtotal: amount_subtotal ?? pricePaidInCents,
      refund: refundAmount,
    }),
  }
}

function getReceiptUrl(paymentIntent: Stripe.PaymentIntent | string | null) {
  if (
    typeof paymentIntent === "string" ||
    typeof paymentIntent?.latest_charge === "string"
  ) {
    return
  }

  return paymentIntent?.latest_charge?.receipt_url
}

function getPricingRows(
  totalDetails: Stripe.Checkout.Session.TotalDetails | null,
  {
    total,
    subtotal,
    refund,
  }: { total: number; subtotal: number; refund?: number }
) {
  const pricingRows: {
    label: string
    amountInDollars: number
    isBold?: boolean
  }[] = []

  if (totalDetails?.breakdown != null) {
    totalDetails.breakdown.discounts.forEach(discount => {
      pricingRows.push({
        label: `${discount.discount.coupon.name} (${discount.discount.coupon.percent_off}% off)`,
        amountInDollars: discount.amount / -100,
      })
    })
  }

  if (refund) {
    pricingRows.push({
      label: "Refund",
      amountInDollars: refund / -100,
    })
  }

  if (pricingRows.length === 0) {
    return [{ label: "Total", amountInDollars: total / 100, isBold: true }]
  }

  return [
    {
      label: "Subtotal",
      amountInDollars: subtotal / 100,
    },
    ...pricingRows,
    {
      label: "Total",
      amountInDollars: total / 100,
      isBold: true,
    },
  ]
}


================================================
FILE: src/app/(consumer)/purchases/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { Button } from "@/components/ui/button"
import { db } from "@/drizzle/db"
import { PurchaseTable } from "@/drizzle/schema"
import {
  UserPurchaseTable,
  UserPurchaseTableSkeleton,
} from "@/features/purchases/components/UserPurchaseTable"
import { getPurchaseUserTag } from "@/features/purchases/db/cache"
import { getCurrentUser } from "@/services/clerk"
import { desc, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import Link from "next/link"
import { Suspense } from "react"

export default function PurchasesPage() {
  return (
    <div className="container my-6">
      <PageHeader title="Purchase History" />
      <Suspense fallback={<UserPurchaseTableSkeleton />}>
        <SuspenseBoundary />
      </Suspense>
    </div>
  )
}

async function SuspenseBoundary() {
  const { userId, redirectToSignIn } = await getCurrentUser()
  if (userId == null) return redirectToSignIn()

  const purchases = await getPurchases(userId)

  if (purchases.length === 0) {
    return (
      <div className="flex flex-col gap-2 items-start">
        You have made no purchases yet
        <Button asChild size="lg">
          <Link href="/">Browse Courses</Link>
        </Button>
      </div>
    )
  }

  return <UserPurchaseTable purchases={purchases} />
}

async function getPurchases(userId: string) {
  "use cache"
  cacheTag(getPurchaseUserTag(userId))

  return db.query.PurchaseTable.findMany({
    columns: {
      id: true,
      pricePaidInCents: true,
      refundedAt: true,
      productDetails: true,
      createdAt: true,
    },
    where: eq(PurchaseTable.userId, userId),
    orderBy: desc(PurchaseTable.createdAt),
  })
}


================================================
FILE: src/app/admin/courses/[courseId]/edit/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { DialogTrigger } from "@/components/ui/dialog"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { db } from "@/drizzle/db"
import { CourseSectionTable, CourseTable, LessonTable } from "@/drizzle/schema"
import { CourseForm } from "@/features/courses/components/CourseForm"
import { getCourseIdTag } from "@/features/courses/db/cache/courses"
import { SectionFormDialog } from "@/features/courseSections/components/SectionFormDialog"
import { SortableSectionList } from "@/features/courseSections/components/SortableSectionList"
import { getCourseSectionCourseTag } from "@/features/courseSections/db/cache"
import { LessonFormDialog } from "@/features/lessons/components/LessonFormDialog"
import { SortableLessonList } from "@/features/lessons/components/SortableLessonList"
import { getLessonCourseTag } from "@/features/lessons/db/cache/lessons"
import { cn } from "@/lib/utils"
import { asc, eq } from "drizzle-orm"
import { EyeClosed, PlusIcon } from "lucide-react"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { notFound } from "next/navigation"

export default async function EditCoursePage({
  params,
}: {
  params: Promise<{ courseId: string }>
}) {
  const { courseId } = await params
  const course = await getCourse(courseId)

  if (course == null) return notFound()

  return (
    <div className="container my-6">
      <PageHeader title={course.name} />
      <Tabs defaultValue="lessons">
        <TabsList>
          <TabsTrigger value="lessons">Lessons</TabsTrigger>
          <TabsTrigger value="details">Details</TabsTrigger>
        </TabsList>
        <TabsContent value="lessons" className="flex flex-col gap-2">
          <Card>
            <CardHeader className="flex items-center flex-row justify-between">
              <CardTitle>Sections</CardTitle>
              <SectionFormDialog courseId={course.id}>
                <DialogTrigger asChild>
                  <Button variant="outline">
                    <PlusIcon /> New Section
                  </Button>
                </DialogTrigger>
              </SectionFormDialog>
            </CardHeader>
            <CardContent>
              <SortableSectionList
                courseId={course.id}
                sections={course.courseSections}
              />
            </CardContent>
          </Card>
          <hr className="my-2" />
          {course.courseSections.map(section => (
            <Card key={section.id}>
              <CardHeader className="flex items-center flex-row justify-between gap-4">
                <CardTitle
                  className={cn(
                    "flex items-center gap-2",
                    section.status === "private" && "text-muted-foreground"
                  )}
                >
                  {section.status === "private" && <EyeClosed />} {section.name}
                </CardTitle>
                <LessonFormDialog
                  defaultSectionId={section.id}
                  sections={course.courseSections}
                >
                  <DialogTrigger asChild>
                    <Button variant="outline">
                      <PlusIcon /> New Lesson
                    </Button>
                  </DialogTrigger>
                </LessonFormDialog>
              </CardHeader>
              <CardContent>
                <SortableLessonList
                  sections={course.courseSections}
                  lessons={section.lessons}
                />
              </CardContent>
            </Card>
          ))}
        </TabsContent>
        <TabsContent value="details">
          <Card>
            <CardHeader>
              <CourseForm course={course} />
            </CardHeader>
          </Card>
        </TabsContent>
      </Tabs>
    </div>
  )
}

async function getCourse(id: string) {
  "use cache"
  cacheTag(
    getCourseIdTag(id),
    getCourseSectionCourseTag(id),
    getLessonCourseTag(id)
  )

  return db.query.CourseTable.findFirst({
    columns: { id: true, name: true, description: true },
    where: eq(CourseTable.id, id),
    with: {
      courseSections: {
        orderBy: asc(CourseSectionTable.order),
        columns: { id: true, status: true, name: true },
        with: {
          lessons: {
            orderBy: asc(LessonTable.order),
            columns: {
              id: true,
              name: true,
              status: true,
              description: true,
              youtubeVideoId: true,
              sectionId: true,
            },
          },
        },
      },
    },
  })
}


================================================
FILE: src/app/admin/courses/new/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { CourseForm } from "@/features/courses/components/CourseForm"

export default function NewCoursePage() {
  return (
    <div className="container my-6">
      <PageHeader title="New Course" />
      <CourseForm />
    </div>
  )
}


================================================
FILE: src/app/admin/courses/page.tsx
================================================
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/PageHeader"
import Link from "next/link"
import { CourseTable } from "@/features/courses/components/CourseTable"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { getCourseGlobalTag } from "@/features/courses/db/cache/courses"
import { db } from "@/drizzle/db"
import {
  CourseSectionTable,
  CourseTable as DbCourseTable,
  LessonTable,
  UserCourseAccessTable,
} from "@/drizzle/schema"
import { asc, countDistinct, eq } from "drizzle-orm"
import { getUserCourseAccessGlobalTag } from "@/features/courses/db/cache/userCourseAccess"
import { getCourseSectionGlobalTag } from "@/features/courseSections/db/cache"
import { getLessonGlobalTag } from "@/features/lessons/db/cache/lessons"

export default async function CoursesPage() {
  const courses = await getCourses()

  return (
    <div className="container my-6">
      <PageHeader title="Courses">
        <Button asChild>
          <Link href="/admin/courses/new">New Course</Link>
        </Button>
      </PageHeader>

      <CourseTable courses={courses} />
    </div>
  )
}

async function getCourses() {
  "use cache"
  cacheTag(
    getCourseGlobalTag(),
    getUserCourseAccessGlobalTag(),
    getCourseSectionGlobalTag(),
    getLessonGlobalTag()
  )

  return db
    .select({
      id: DbCourseTable.id,
      name: DbCourseTable.name,
      sectionsCount: countDistinct(CourseSectionTable),
      lessonsCount: countDistinct(LessonTable),
      studentsCount: countDistinct(UserCourseAccessTable),
    })
    .from(DbCourseTable)
    .leftJoin(
      CourseSectionTable,
      eq(CourseSectionTable.courseId, DbCourseTable.id)
    )
    .leftJoin(LessonTable, eq(LessonTable.sectionId, CourseSectionTable.id))
    .leftJoin(
      UserCourseAccessTable,
      eq(UserCourseAccessTable.courseId, DbCourseTable.id)
    )
    .orderBy(asc(DbCourseTable.name))
    .groupBy(DbCourseTable.id)
}


================================================
FILE: src/app/admin/layout.tsx
================================================
import { Badge } from "@/components/ui/badge"
import { UserButton } from "@clerk/nextjs"
import Link from "next/link"
import { ReactNode } from "react"

export default function AdminLayout({
  children,
}: Readonly<{ children: ReactNode }>) {
  return (
    <>
      <Navbar />
      {children}
    </>
  )
}

function Navbar() {
  return (
    <header className="flex h-12 shadow bg-background z-10">
      <nav className="flex gap-4 container">
        <div className="mr-auto flex items-center gap-2">
          <Link className="text-lg hover:underline" href="/admin">
            Web Dev Simplified
          </Link>
          <Badge>Admin</Badge>
        </div>
        <Link
          className="hover:bg-accent/10 flex items-center px-2"
          href="/admin/courses"
        >
          Courses
        </Link>
        <Link
          className="hover:bg-accent/10 flex items-center px-2"
          href="/admin/products"
        >
          Products
        </Link>
        <Link
          className="hover:bg-accent/10 flex items-center px-2"
          href="/admin/sales"
        >
          Sales
        </Link>
        <div className="size-8 self-center">
          <UserButton
            appearance={{
              elements: {
                userButtonAvatarBox: { width: "100%", height: "100%" },
              },
            }}
          />
        </div>
      </nav>
    </header>
  )
}


================================================
FILE: src/app/admin/page.tsx
================================================
import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card"
import { db } from "@/drizzle/db"
import {
  CourseSectionTable,
  CourseTable,
  LessonTable,
  ProductTable,
  PurchaseTable,
  UserCourseAccessTable,
} from "@/drizzle/schema"
import { getCourseGlobalTag } from "@/features/courses/db/cache/courses"
import { getUserCourseAccessGlobalTag } from "@/features/courses/db/cache/userCourseAccess"
import { getCourseSectionGlobalTag } from "@/features/courseSections/db/cache"
import { getLessonGlobalTag } from "@/features/lessons/db/cache/lessons"
import { getProductGlobalTag } from "@/features/products/db/cache"
import { getPurchaseGlobalTag } from "@/features/purchases/db/cache"
import { formatNumber, formatPrice } from "@/lib/formatters"
import { count, countDistinct, isNotNull, sql, sum } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { ReactNode } from "react"

export default async function AdminPage() {
  const {
    averageNetPurchasesPerCustomer,
    netPurchases,
    netSales,
    refundedPurchases,
    totalRefunds,
  } = await getPurchaseDetails()

  return (
    <div className="container my-6">
      <div className="grid grid-cols-1 sm:grid-cols-3 lg:grid-cols-5 md:grid-cols-4 gap-4">
        <StatCard title="Net Sales">{formatPrice(netSales)}</StatCard>
        <StatCard title="Refunded Sales">{formatPrice(totalRefunds)}</StatCard>
        <StatCard title="Un-Refunded Purchases">
          {formatNumber(netPurchases)}
        </StatCard>
        <StatCard title="Refunded Purchases">
          {formatNumber(refundedPurchases)}
        </StatCard>
        <StatCard title="Purchases Per User">
          {formatNumber(averageNetPurchasesPerCustomer, {
            maximumFractionDigits: 2,
          })}
        </StatCard>
        <StatCard title="Students">
          {formatNumber(await getTotalStudents())}
        </StatCard>
        <StatCard title="Products">
          {formatNumber(await getTotalProducts())}
        </StatCard>
        <StatCard title="Courses">
          {formatNumber(await getTotalCourses())}
        </StatCard>
        <StatCard title="CourseSections">
          {formatNumber(await getTotalCourseSections())}
        </StatCard>
        <StatCard title="Lessons">
          {formatNumber(await getTotalLessons())}
        </StatCard>
      </div>
    </div>
  )
}

function StatCard({ title, children }: { title: string; children: ReactNode }) {
  return (
    <Card>
      <CardHeader className="text-center">
        <CardDescription>{title}</CardDescription>
        <CardTitle className="font-bold text-2xl">{children}</CardTitle>
      </CardHeader>
    </Card>
  )
}

async function getPurchaseDetails() {
  "use cache"
  cacheTag(getPurchaseGlobalTag())

  const data = await db
    .select({
      totalSales: sql<number>`COALESCE(${sum(
        PurchaseTable.pricePaidInCents
      )}, 0)`.mapWith(Number),
      totalPurchases: count(PurchaseTable.id),
      totalUsers: countDistinct(PurchaseTable.userId),
      isRefund: isNotNull(PurchaseTable.refundedAt),
    })
    .from(PurchaseTable)
    .groupBy(table => table.isRefund)

  const [refundData] = data.filter(row => row.isRefund)
  const [salesData] = data.filter(row => !row.isRefund)

  const netSales = (salesData?.totalSales ?? 0) / 100
  const totalRefunds = (refundData?.totalSales ?? 0) / 100
  const netPurchases = salesData?.totalPurchases ?? 0
  const refundedPurchases = refundData?.totalPurchases ?? 0
  const averageNetPurchasesPerCustomer =
    salesData?.totalUsers != null && salesData.totalUsers > 0
      ? netPurchases / salesData.totalUsers
      : 0

  return {
    netSales,
    totalRefunds,
    netPurchases,
    refundedPurchases,
    averageNetPurchasesPerCustomer,
  }
}

async function getTotalStudents() {
  "use cache"
  cacheTag(getUserCourseAccessGlobalTag())

  const [data] = await db
    .select({ totalStudents: countDistinct(UserCourseAccessTable.userId) })
    .from(UserCourseAccessTable)

  if (data == null) return 0
  return data.totalStudents
}

async function getTotalCourses() {
  "use cache"
  cacheTag(getCourseGlobalTag())

  const [data] = await db
    .select({ totalCourses: count(CourseTable.id) })
    .from(CourseTable)

  if (data == null) return 0
  return data.totalCourses
}

async function getTotalProducts() {
  "use cache"
  cacheTag(getProductGlobalTag())

  const [data] = await db
    .select({ totalProducts: count(ProductTable.id) })
    .from(ProductTable)
  if (data == null) return 0
  return data.totalProducts
}

async function getTotalLessons() {
  "use cache"
  cacheTag(getLessonGlobalTag())

  const [data] = await db
    .select({ totalLessons: count(LessonTable.id) })
    .from(LessonTable)
  if (data == null) return 0
  return data.totalLessons
}

async function getTotalCourseSections() {
  "use cache"
  cacheTag(getCourseSectionGlobalTag())

  const [data] = await db
    .select({ totalCourseSections: count(CourseSectionTable.id) })
    .from(CourseSectionTable)
  if (data == null) return 0
  return data.totalCourseSections
}


================================================
FILE: src/app/admin/products/[productId]/edit/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { db } from "@/drizzle/db"
import { CourseTable, ProductTable } from "@/drizzle/schema"
import { getCourseGlobalTag } from "@/features/courses/db/cache/courses"
import { ProductForm } from "@/features/products/components/ProductForm"
import { getProductIdTag } from "@/features/products/db/cache"
import { asc, eq } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { notFound } from "next/navigation"

export default async function EditProductPage({
  params,
}: {
  params: Promise<{ productId: string }>
}) {
  const { productId } = await params
  const product = await getProduct(productId)

  if (product == null) return notFound()

  return (
    <div className="container my-6">
      <PageHeader title="New Product" />
      <ProductForm
        product={{
          ...product,
          courseIds: product.courseProducts.map(c => c.courseId),
        }}
        courses={await getCourses()}
      />
    </div>
  )
}

async function getCourses() {
  "use cache"
  cacheTag(getCourseGlobalTag())

  return db.query.CourseTable.findMany({
    orderBy: asc(CourseTable.name),
    columns: { id: true, name: true },
  })
}

async function getProduct(id: string) {
  "use cache"
  cacheTag(getProductIdTag(id))

  return db.query.ProductTable.findFirst({
    columns: {
      id: true,
      name: true,
      description: true,
      priceInDollars: true,
      status: true,
      imageUrl: true,
    },
    where: eq(ProductTable.id, id),
    with: { courseProducts: { columns: { courseId: true } } },
  })
}


================================================
FILE: src/app/admin/products/new/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { db } from "@/drizzle/db"
import { CourseTable } from "@/drizzle/schema"
import { getCourseGlobalTag } from "@/features/courses/db/cache/courses"
import { ProductForm } from "@/features/products/components/ProductForm"
import { asc } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"

export default async function NewProductPage() {
  return (
    <div className="container my-6">
      <PageHeader title="New Product" />
      <ProductForm courses={await getCourses()} />
    </div>
  )
}

async function getCourses() {
  "use cache"
  cacheTag(getCourseGlobalTag())

  return db.query.CourseTable.findMany({
    orderBy: asc(CourseTable.name),
    columns: { id: true, name: true },
  })
}


================================================
FILE: src/app/admin/products/page.tsx
================================================
import { Button } from "@/components/ui/button"
import { PageHeader } from "@/components/PageHeader"
import Link from "next/link"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"
import { db } from "@/drizzle/db"
import {
  CourseProductTable,
  ProductTable as DbProductTable,
  PurchaseTable,
} from "@/drizzle/schema"
import { asc, countDistinct, eq } from "drizzle-orm"
import { getProductGlobalTag } from "@/features/products/db/cache"
import { ProductTable } from "@/features/products/components/ProductTable"

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div className="container my-6">
      <PageHeader title="Products">
        <Button asChild>
          <Link href="/admin/products/new">New Product</Link>
        </Button>
      </PageHeader>

      <ProductTable products={products} />
    </div>
  )
}

async function getProducts() {
  "use cache"
  cacheTag(getProductGlobalTag())

  return db
    .select({
      id: DbProductTable.id,
      name: DbProductTable.name,
      status: DbProductTable.status,
      priceInDollars: DbProductTable.priceInDollars,
      description: DbProductTable.description,
      imageUrl: DbProductTable.imageUrl,
      coursesCount: countDistinct(CourseProductTable.courseId),
      customersCount: countDistinct(PurchaseTable.userId),
    })
    .from(DbProductTable)
    .leftJoin(PurchaseTable, eq(PurchaseTable.productId, DbProductTable.id))
    .leftJoin(
      CourseProductTable,
      eq(CourseProductTable.productId, DbProductTable.id)
    )
    .orderBy(asc(DbProductTable.name))
    .groupBy(DbProductTable.id)
}


================================================
FILE: src/app/admin/sales/page.tsx
================================================
import { PageHeader } from "@/components/PageHeader"
import { db } from "@/drizzle/db"
import { PurchaseTable as DbPurchaseTable } from "@/drizzle/schema"
import { PurchaseTable } from "@/features/purchases/components/PurchaseTable"
import { getPurchaseGlobalTag } from "@/features/purchases/db/cache"
import { getUserGlobalTag } from "@/features/users/db/cache"
import { desc } from "drizzle-orm"
import { cacheTag } from "next/dist/server/use-cache/cache-tag"

export default async function PurchasesPage() {
  const purchases = await getPurchases()

  return (
    <div className="container my-6">
      <PageHeader title="Sales" />

      <PurchaseTable purchases={purchases} />
    </div>
  )
}

async function getPurchases() {
  "use cache"
  cacheTag(getPurchaseGlobalTag(), getUserGlobalTag())

  return db.query.PurchaseTable.findMany({
    columns: {
      id: true,
      pricePaidInCents: true,
      refundedAt: true,
      productDetails: true,
      createdAt: true,
    },
    orderBy: desc(DbPurchaseTable.createdAt),
    with: { user: { columns: { name: true } } },
  })
}


================================================
FILE: src/app/api/clerk/syncUsers/route.ts
================================================
import { insertUser } from "@/features/users/db/users"
import { syncClerkUserMetadata } from "@/services/clerk"
import { currentUser } from "@clerk/nextjs/server"
import { NextResponse } from "next/server"

export async function GET(request: Request) {
  const user = await currentUser()

  if (user == null) return new Response("User not found", { status: 500 })
  if (user.fullName == null) {
    return new Response("User name missing", { status: 500 })
  }
  if (user.primaryEmailAddress?.emailAddress == null) {
    return new Response("User email missing", { status: 500 })
  }

  const dbUser = await insertUser({
    clerkUserId: user.id,
    name: user.fullName,
    email: user.primaryEmailAddress.emailAddress,
    imageUrl: user.imageUrl,
    role: user.publicMetadata.role ?? "user",
  })

  await syncClerkUserMetadata(dbUser)

  await new Promise(res => setTimeout(res, 100))

  return NextResponse.redirect(request.headers.get("referer") ?? "/")
}


================================================
FILE: src/app/api/webhooks/clerk/route.ts
================================================
import { env } from "@/data/env/server"
import { deleteUser, insertUser, updateUser } from "@/features/users/db/users"
import { syncClerkUserMetadata } from "@/services/clerk"
import { WebhookEvent } from "@clerk/nextjs/server"
import { headers } from "next/headers"
import { Webhook } from "svix"

export async function POST(req: Request) {
  const headerPayload = await headers()
  const svixId = headerPayload.get("svix-id")
  const svixTimestamp = headerPayload.get("svix-timestamp")
  const svixSignature = headerPayload.get("svix-signature")

  if (!svixId || !svixTimestamp || !svixSignature) {
    return new Response("Error occurred -- no svix headers", {
      status: 400,
    })
  }

  const payload = await req.json()
  const body = JSON.stringify(payload)

  const wh = new Webhook(env.CLERK_WEBHOOK_SECRET)
  let event: WebhookEvent

  try {
    event = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    }) as WebhookEvent
  } catch (err) {
    console.error("Error verifying webhook:", err)
    return new Response("Error occurred", {
      status: 400,
    })
  }

  switch (event.type) {
    case "user.created":
    case "user.updated": {
      const email = event.data.email_addresses.find(
        email => email.id === event.data.primary_email_address_id
      )?.email_address
      const name = `${event.data.first_name} ${event.data.last_name}`.trim()
      if (email == null) return new Response("No email", { status: 400 })
      if (name === "") return new Response("No name", { status: 400 })

      if (event.type === "user.created") {
        const user = await insertUser({
          clerkUserId: event.data.id,
          email,
          name,
          imageUrl: event.data.image_url,
          role: "user",
        })

        await syncClerkUserMetadata(user)
      } else {
        await updateUser(
          { clerkUserId: event.data.id },
          {
            email,
            name,
            imageUrl: event.data.image_url,
            role: event.data.public_metadata.role,
          }
        )
      }
      break
    }
    case "user.deleted": {
      if (event.data.id != null) {
        await deleteUser({ clerkUserId: event.data.id })
      }
      break
    }
  }

  return new Response("", { status: 200 })
}


================================================
FILE: src/app/api/webhooks/stripe/route.ts
================================================
import { env } from "@/data/env/server"
import { db } from "@/drizzle/db"
import { ProductTable, UserTable } from "@/drizzle/schema"
import { addUserCourseAccess } from "@/features/courses/db/userCourseAcccess"
import { insertPurchase } from "@/features/purchases/db/purchases"
import { stripeServerClient } from "@/services/stripe/stripeServer"
import { eq } from "drizzle-orm"
import { redirect } from "next/navigation"
import { NextRequest, NextResponse } from "next/server"
import Stripe from "stripe"

export async function GET(request: NextRequest) {
  const stripeSessionId = request.nextUrl.searchParams.get("stripeSessionId")
  if (stripeSessionId == null) redirect("/products/purchase-failure")

  let redirectUrl: string
  try {
    const checkoutSession = await stripeServerClient.checkout.sessions.retrieve(
      stripeSessionId,
      { expand: ["line_items"] }
    )
    const productId = await processStripeCheckout(checkoutSession)

    redirectUrl = `/products/${productId}/purchase/success`
  } catch {
    redirectUrl = "/products/purchase-failure"
  }

  return NextResponse.redirect(new URL(redirectUrl, request.url))
}

export async function POST(request: NextRequest) {
  const event = await stripeServerClient.webhooks.constructEvent(
    await request.text(),
    request.headers.get("stripe-signature") as string,
    env.STRIPE_WEBHOOK_SECRET
  )

  switch (event.type) {
    case "checkout.session.completed":
    case "checkout.session.async_payment_succeeded": {
      try {
        await processStripeCheckout(event.data.object)
      } catch {
        return new Response(null, { status: 500 })
      }
    }
  }
  return new Response(null, { status: 200 })
}

async function processStripeCheckout(checkoutSession: Stripe.Checkout.Session) {
  const userId = checkoutSession.metadata?.userId
  const productId = checkoutSession.metadata?.productId

  if (userId == null || productId == null) {
    throw new Error("Missing metadata")
  }

  const [product, user] = await Promise.all([
    getProduct(productId),
    await getUser(userId),
  ])

  if (product == null) throw new Error("Product not found")
  if (user == null) throw new Error("User not found")

  const courseIds = product.courseProducts.map(cp => cp.courseId)
  db.transaction(async trx => {
    try {
      await addUserCourseAccess({ userId: user.id, courseIds }, trx)
      await insertPurchase(
        {
          stripeSessionId: checkoutSession.id,
          pricePaidInCents:
            checkoutSession.amount_total || product.priceInDollars * 100,
          productDetails: product,
          userId: user.id,
          productId,
        },
        trx
      )
    } catch (error) {
      trx.rollback()
      throw error
    }
  })

  return productId
}

function getProduct(id: string) {
  return db.query.ProductTable.findFirst({
    columns: {
      id: true,
      priceInDollars: true,
      name: true,
      description: true,
      imageUrl: true,
    },
    where: eq(ProductTable.id, id),
    with: {
      courseProducts: { columns: { courseId: true } },
    },
  })
}

function getUser(id: string) {
  return db.query.UserTable.findFirst({
    columns: { id: true },
    where: eq(UserTable.id, id),
  })
}


================================================
FILE: src/app/globals.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  font-family: Arial, Helvetica, sans-serif;
}

@layer base {
  :root {
    --background: 0 0% 100%;
    --foreground: 240 10% 3.9%;
    --card: 0 0% 100%;
    --card-foreground: 240 10% 3.9%;
    --popover: 0 0% 100%;
    --popover-foreground: 240 10% 3.9%;
    --primary: 240 5.9% 10%;
    --primary-foreground: 0 0% 98%;
    --secondary: 240 4.8% 95.9%;
    --secondary-foreground: 240 5.9% 10%;
    --muted: 240 4.8% 95.9%;
    --muted-foreground: 240 3.8% 46.1%;
    --accent: 280 75% 50%;
    --accent-foreground: 0 0% 98%;
    --destructive: 0 84.2% 60.2%;
    --destructive-foreground: 0 0% 98%;
    --border: 240 5.9% 90%;
    --input: 240 5.9% 90%;
    --ring: 240 10% 3.9%;
    --chart-1: 12 76% 61%;
    --chart-2: 173 58% 39%;
    --chart-3: 197 37% 24%;
    --chart-4: 43 74% 66%;
    --chart-5: 27 87% 67%;
    --radius: 0.5rem;
  }
}

@layer base {
  * {
    @apply border-border;
  }
  body {
    @apply bg-background text-foreground;
  }
}


================================================
FILE: src/app/layout.tsx
================================================
import type { Metadata } from "next"
import "./globals.css"
import { ClerkProvider } from "@clerk/nextjs"
import { Toaster } from "@/components/ui/toaster"

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <ClerkProvider>
      <html lang="en">
        <body className="antialiased">
          {children}
          <Toaster />
        </body>
      </html>
    </ClerkProvider>
  )
}


================================================
FILE: src/components/ActionButton.tsx
================================================
"use client"

import { ComponentPropsWithRef, ReactNode, useTransition } from "react"
import { Button } from "./ui/button"
import { actionToast } from "@/hooks/use-toast"
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
  AlertDialog,
  AlertDialogDescription,
  AlertDialogHeader,
  AlertDialogTitle,
  AlertDialogContent,
  AlertDialogTrigger,
  AlertDialogFooter,
  AlertDialogCancel,
  AlertDialogAction,
} from "./ui/alert-dialog"

export function ActionButton({
  action,
  requireAreYouSure = false,
  ...props
}: Omit<ComponentPropsWithRef<typeof Button>, "onClick"> & {
  action: () => Promise<{ error: boolean; message: string }>
  requireAreYouSure?: boolean
}) {
  {
    const [isLoading, startTransition] = useTransition()

    function performAction() {
      startTransition(async () => {
        const data = await action()
        actionToast({ actionData: data })
      })
    }

    if (requireAreYouSure) {
      return (
        <AlertDialog open={isLoading ? true : undefined}>
          <AlertDialogTrigger asChild>
            <Button {...props} />
          </AlertDialogTrigger>
          <AlertDialogContent>
            <AlertDialogHeader>
              <AlertDialogTitle>Are you sure?</AlertDialogTitle>
              <AlertDialogDescription>
                This action cannot be undone.
              </AlertDialogDescription>
            </AlertDialogHeader>
            <AlertDialogFooter>
              <AlertDialogCancel>Cancel</AlertDialogCancel>
              <AlertDialogAction disabled={isLoading} onClick={performAction}>
                <LoadingTextSwap isLoading={isLoading}>Yes</LoadingTextSwap>
              </AlertDialogAction>
            </AlertDialogFooter>
          </AlertDialogContent>
        </AlertDialog>
      )
    }

    return (
      <Button {...props} disabled={isLoading} onClick={performAction}>
        <LoadingTextSwap isLoading={isLoading}>
          {props.children}
        </LoadingTextSwap>
      </Button>
    )
  }
}

function LoadingTextSwap({
  isLoading,
  children,
}: {
  isLoading: boolean
  children: ReactNode
}) {
  return (
    <div className="grid items-center justify-items-center">
      <div
        className={cn(
          "col-start-1 col-end-2 row-start-1 row-end-2",
          isLoading ? "invisible" : "visible"
        )}
      >
        {children}
      </div>
      <div
        className={cn(
          "col-start-1 col-end-2 row-start-1 row-end-2 text-center",
          isLoading ? "visible" : "invisible"
        )}
      >
        <Loader2Icon className="animate-spin" />
      </div>
    </div>
  )
}


================================================
FILE: src/components/LoadingSpinner.tsx
================================================
import { cn } from "@/lib/utils"
import { Loader2Icon } from "lucide-react"
import { ComponentProps } from "react"

export function LoadingSpinner({
  className,
  ...props
}: ComponentProps<typeof Loader2Icon>) {
  return (
    <Loader2Icon
      className={cn("animate-spin text-accent", className)}
      {...props}
    />
  )
}


================================================
FILE: src/components/PageHeader.tsx
================================================
import { cn } from "@/lib/utils"
import { ReactNode } from "react"

export function PageHeader({
  title,
  children,
  className,
}: {
  title: string
  children?: ReactNode
  className?: string
}) {
  return (
    <div
      className={cn("mb-8 flex gap-4 items-center justify-between", className)}
    >
      <h1 className="text-2xl font-semibold">{title}</h1>
      {children && <div>{children}</div>}
    </div>
  )
}


================================================
FILE: src/components/RequiredLabelIcon.tsx
================================================
import { cn } from "@/lib/utils"
import { AsteriskIcon } from "lucide-react"
import { ComponentPropsWithoutRef } from "react"

export function RequiredLabelIcon({
  className,
  ...props
}: ComponentPropsWithoutRef<typeof AsteriskIcon>) {
  return (
    <AsteriskIcon
      {...props}
      className={cn("text-destructive inline size-4 align-top", className)}
    />
  )
}


================================================
FILE: src/components/Skeleton.tsx
================================================
import { cn } from "@/lib/utils"
import { buttonVariants } from "./ui/button"
import { ReactNode } from "react"

export function SkeletonButton({ className }: { className?: string }) {
  return (
    <div
      className={cn(
        buttonVariants({
          variant: "secondary",
          className: "pointer-events-none animate-pulse w-24",
        }),
        className
      )}
    />
  )
}

export function SkeletonArray({
  amount,
  children,
}: {
  amount: number
  children: ReactNode
}) {
  return Array.from({ length: amount }).map(() => children)
}

export function SkeletonText({
  rows = 1,
  size = "md",
  className,
}: {
  rows?: number
  size?: "md" | "lg"
  className?: string
}) {
  return (
    <div className="flex flex-col gap-1">
      <SkeletonArray amount={rows}>
        <div
          className={cn(
            "bg-secondary animate-pulse w-full rounded-sm",
            rows > 1 && "last:w-3/4",
            size === "md" && "h-3",
            size === "lg" && "h-5",
            className
          )}
        />
      </SkeletonArray>
    </div>
  )
}


================================================
FILE: src/components/SortableList.tsx
================================================
"use client"

import { ReactNode, useId, useOptimistic, useTransition } from "react"
import { DndContext, DragEndEvent } from "@dnd-kit/core"
import {
  arrayMove,
  SortableContext,
  useSortable,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { cn } from "@/lib/utils"
import { GripVerticalIcon } from "lucide-react"
import { actionToast } from "@/hooks/use-toast"

export function SortableList<T extends { id: string }>({
  items,
  onOrderChange,
  children,
}: {
  items: T[]
  onOrderChange: (
    newOrder: string[]
  ) => Promise<{ error: boolean; message: string }>
  children: (items: T[]) => ReactNode
}) {
  const dndContextId = useId()
  const [optimisticItems, setOptimisticItems] = useOptimistic(items)
  const [, startTransition] = useTransition()

  function handleDragEnd(event: DragEndEvent) {
    const { active, over } = event
    const activeId = active.id.toString()
    const overId = over?.id.toString()
    if (overId == null || activeId == null) return

    function getNewArray(array: T[], activeId: string, overId: string) {
      const oldIndex = array.findIndex(section => section.id === activeId)
      const newIndex = array.findIndex(section => section.id === overId)
      return arrayMove(array, oldIndex, newIndex)
    }

    startTransition(async () => {
      setOptimisticItems(items => getNewArray(items, activeId, overId))
      const actionData = await onOrderChange(
        getNewArray(optimisticItems, activeId, overId).map(s => s.id)
      )

      actionToast({ actionData })
    })
  }

  return (
    <DndContext id={dndContextId} onDragEnd={handleDragEnd}>
      <SortableContext
        items={optimisticItems}
        strategy={verticalListSortingStrategy}
      >
        <div className="flex flex-col">{children(optimisticItems)}</div>
      </SortableContext>
    </DndContext>
  )
}

export function SortableItem({
  id,
  children,
  className,
}: {
  id: string
  children: ReactNode
  className?: string
}) {
  const {
    setNodeRef,
    transform,
    transition,
    activeIndex,
    index,
    attributes,
    listeners,
  } = useSortable({ id })
  const isActive = activeIndex === index

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
      }}
      className={cn(
        "flex gap-1 items-center bg-background rounded-lg p-2",
        isActive && "z-10 border shadow-md"
      )}
    >
      <GripVerticalIcon
        className="text-muted-foreground size-6 p-1"
        {...attributes}
        {...listeners}
      />
      <div className={cn("flex-grow", className)}>{children}</div>
    </div>
  )
}


================================================
FILE: src/components/ui/accordion.tsx
================================================
"use client"

import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDown } from "lucide-react"

import { cn } from "@/lib/utils"

const Accordion = AccordionPrimitive.Root

const AccordionItem = React.forwardRef<
  React.ElementRef<typeof AccordionPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
>(({ className, ...props }, ref) => (
  <AccordionPrimitive.Item
    ref={ref}
    className={cn("border-b", className)}
    {...props}
  />
))
AccordionItem.displayName = "AccordionItem"

const AccordionTrigger = React.forwardRef<
  React.ElementRef<typeof AccordionPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
  <AccordionPrimitive.Header className="flex">
    <AccordionPrimitive.Trigger
      ref={ref}
      className={cn(
        "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",
        className
      )}
      {...props}
    >
      {children}
      <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200" />
    </AccordionPrimitive.Trigger>
  </AccordionPrimitive.Header>
))
AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName

const AccordionContent = React.forwardRef<
  React.ElementRef<typeof AccordionPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <AccordionPrimitive.Content
    ref={ref}
    className="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
    {...props}
  >
    <div className={cn("pb-4 pt-0", className)}>{children}</div>
  </AccordionPrimitive.Content>
))
AccordionContent.displayName = AccordionPrimitive.Content.displayName

export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }


================================================
FILE: src/components/ui/alert-dialog.tsx
================================================
"use client"

import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"

import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"

const AlertDialog = AlertDialogPrimitive.Root

const AlertDialogTrigger = AlertDialogPrimitive.Trigger

const AlertDialogPortal = AlertDialogPrimitive.Portal

const AlertDialogOverlay = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Overlay
    className={cn(
      "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",
      className
    )}
    {...props}
    ref={ref}
  />
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName

const AlertDialogContent = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
  <AlertDialogPortal>
    <AlertDialogOverlay />
    <AlertDialogPrimitive.Content
      ref={ref}
      className={cn(
        "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",
        className
      )}
      {...props}
    />
  </AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName

const AlertDialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-2 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
AlertDialogHeader.displayName = "AlertDialogHeader"

const AlertDialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
AlertDialogFooter.displayName = "AlertDialogFooter"

const AlertDialogTitle = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Title
    ref={ref}
    className={cn("text-lg font-semibold", className)}
    {...props}
  />
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName

const AlertDialogDescription = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
AlertDialogDescription.displayName =
  AlertDialogPrimitive.Description.displayName

const AlertDialogAction = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Action>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Action
    ref={ref}
    className={cn(buttonVariants(), className)}
    {...props}
  />
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName

const AlertDialogCancel = React.forwardRef<
  React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
  React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
  <AlertDialogPrimitive.Cancel
    ref={ref}
    className={cn(
      buttonVariants({ variant: "outline" }),
      "mt-2 sm:mt-0",
      className
    )}
    {...props}
  />
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName

export {
  AlertDialog,
  AlertDialogPortal,
  AlertDialogOverlay,
  AlertDialogTrigger,
  AlertDialogContent,
  AlertDialogHeader,
  AlertDialogFooter,
  AlertDialogTitle,
  AlertDialogDescription,
  AlertDialogAction,
  AlertDialogCancel,
}


================================================
FILE: src/components/ui/badge.tsx
================================================
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const badgeVariants = cva(
  "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
  {
    variants: {
      variant: {
        default:
          "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
        secondary:
          "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
        destructive:
          "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

export interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return (
    <div className={cn(badgeVariants({ variant }), className)} {...props} />
  )
}

export { Badge, badgeVariants }


================================================
FILE: src/components/ui/button.tsx
================================================
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        default:
          "bg-primary text-primary-foreground shadow hover:bg-primary/90",
        destructive:
          "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
        destructiveOutline:
          "border border-destructive bg-background shadow-sm text-destructive hover:bg-destructive hover:text-destructive-foreground",
        outline:
          "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent",
        secondary:
          "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
        ghost: "hover:bg-accent hover:text-accent-foreground",
        link: "text-primary underline-offset-4 hover:underline",
      },
      size: {
        default: "h-9 px-4 py-2",
        sm: "h-8 rounded-md px-3 text-xs",
        lg: "h-10 rounded-md px-8",
        icon: "h-9 w-9",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

export interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ className, variant, size, asChild = false, ...props }, ref) => {
    const Comp = asChild ? Slot : "button"
    return (
      <Comp
        className={cn(buttonVariants({ variant, size, className }))}
        ref={ref}
        {...props}
      />
    )
  }
)
Button.displayName = "Button"

export { Button, buttonVariants }


================================================
FILE: src/components/ui/card.tsx
================================================
import * as React from "react"

import { cn } from "@/lib/utils"

const Card = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn(
      "rounded-xl border bg-card text-card-foreground shadow",
      className
    )}
    {...props}
  />
))
Card.displayName = "Card"

const CardHeader = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex flex-col space-y-1.5 p-6", className)}
    {...props}
  />
))
CardHeader.displayName = "CardHeader"

const CardTitle = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("font-semibold leading-none tracking-tight", className)}
    {...props}
  />
))
CardTitle.displayName = "CardTitle"

const CardDescription = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
CardDescription.displayName = "CardDescription"

const CardContent = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"

const CardFooter = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
  <div
    ref={ref}
    className={cn("flex items-center p-6 pt-0", className)}
    {...props}
  />
))
CardFooter.displayName = "CardFooter"

export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }


================================================
FILE: src/components/ui/command.tsx
================================================
"use client"

import * as React from "react"
import { type DialogProps } from "@radix-ui/react-dialog"
import { Command as CommandPrimitive } from "cmdk"
import { Search } from "lucide-react"

import { cn } from "@/lib/utils"
import { Dialog, DialogContent } from "@/components/ui/dialog"

const Command = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
  <CommandPrimitive
    ref={ref}
    className={cn(
      "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
      className
    )}
    {...props}
  />
))
Command.displayName = CommandPrimitive.displayName

const CommandDialog = ({ children, ...props }: DialogProps) => {
  return (
    <Dialog {...props}>
      <DialogContent className="overflow-hidden p-0">
        <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">
          {children}
        </Command>
      </DialogContent>
    </Dialog>
  )
}

const CommandInput = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Input>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
  <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
    <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
    <CommandPrimitive.Input
      ref={ref}
      className={cn(
        "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",
        className
      )}
      {...props}
    />
  </div>
))

CommandInput.displayName = CommandPrimitive.Input.displayName

const CommandList = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.List>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
  <CommandPrimitive.List
    ref={ref}
    className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
    {...props}
  />
))

CommandList.displayName = CommandPrimitive.List.displayName

const CommandEmpty = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Empty>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
  <CommandPrimitive.Empty
    ref={ref}
    className="py-6 text-center text-sm"
    {...props}
  />
))

CommandEmpty.displayName = CommandPrimitive.Empty.displayName

const CommandGroup = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Group>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
  <CommandPrimitive.Group
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  />
))

CommandGroup.displayName = CommandPrimitive.Group.displayName

const CommandSeparator = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <CommandPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 h-px bg-border", className)}
    {...props}
  />
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName

const CommandItem = React.forwardRef<
  React.ElementRef<typeof CommandPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
  <CommandPrimitive.Item
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  />
))

CommandItem.displayName = CommandPrimitive.Item.displayName

const CommandShortcut = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
  return (
    <span
      className={cn(
        "ml-auto text-xs tracking-widest text-muted-foreground",
        className
      )}
      {...props}
    />
  )
}
CommandShortcut.displayName = "CommandShortcut"

export {
  Command,
  CommandDialog,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
  CommandShortcut,
  CommandSeparator,
}


================================================
FILE: src/components/ui/custom/multi-select.tsx
================================================
"use client"

import * as React from "react"
import { Check, ChevronsUpDown } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import { Badge } from "../badge"

export function MultiSelect<Option>({
  options,
  getValue,
  getLabel,
  selectedValues,
  onSelectedValuesChange,
  selectPlaceholder,
  searchPlaceholder,
  noSearchResultsMessage = "No results",
}: {
  options: Option[]
  getValue: (option: Option) => string
  getLabel: (option: Option) => React.ReactNode
  selectedValues: string[]
  onSelectedValuesChange: (values: string[]) => void
  selectPlaceholder?: string
  searchPlaceholder?: string
  noSearchResultsMessage?: string
}) {
  const [open, setOpen] = React.useState(false)

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <Button
          variant="outline"
          role="combobox"
          aria-expanded={open}
          className="justify-between h-auto py-1.5 px-2 min-h-9 hover:bg-background w-full"
        >
          <div className="flex gap-1 flex-wrap">
            {selectedValues.length > 0 ? (
              selectedValues.map(value => {
                const option = options.find(o => getValue(o) === value)
                if (option == null) return null

                return (
                  <Badge key={getValue(option)} variant="outline">
                    {getLabel(option)}
                  </Badge>
                )
              })
            ) : (
              <span className="text-muted-foreground">{selectPlaceholder}</span>
            )}
          </div>
          <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
        </Button>
      </PopoverTrigger>
      <PopoverContent align="start" className="p-0">
        <Command>
          <CommandInput placeholder={searchPlaceholder} />
          <CommandList>
            <CommandEmpty>{noSearchResultsMessage}</CommandEmpty>
            <CommandGroup>
              {options.map(option => (
                <CommandItem
                  key={getValue(option)}
                  value={getValue(option)}
                  onSelect={currentValue => {
                    if (selectedValues.includes(currentValue)) {
                      onSelectedValuesChange(
                        selectedValues.filter(value => value !== currentValue)
                      )
                    } else {
                      return onSelectedValuesChange([
                        ...selectedValues,
                        currentValue,
                      ])
                    }
                  }}
                >
                  <Check
                    className={cn(
                      "mr-2 h-4 w-4",
                      selectedValues.includes(getValue(option))
                        ? "opacity-100"
                        : "opacity-0"
                    )}
                  />
                  {getLabel(option)}
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}


================================================
FILE: src/components/ui/dialog.tsx
================================================
"use client"

import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const DialogOverlay = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Overlay>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Overlay
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  />
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPortal>
    <DialogOverlay />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "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",
        className
      )}
      {...props}
    >
      {children}
      <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">
        <X className="h-4 w-4" />
        <span className="sr-only">Close</span>
      </DialogPrimitive.Close>
    </DialogPrimitive.Content>
  </DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col space-y-1.5 text-center sm:text-left",
      className
    )}
    {...props}
  />
)
DialogHeader.displayName = "DialogHeader"

const DialogFooter = ({
  className,
  ...props
}: React.HTMLAttributes<HTMLDivElement>) => (
  <div
    className={cn(
      "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
      className
    )}
    {...props}
  />
)
DialogFooter.displayName = "DialogFooter"

const DialogTitle = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Title>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Title
    ref={ref}
    className={cn(
      "text-lg font-semibold leading-none tracking-tight",
      className
    )}
    {...props}
  />
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

const DialogDescription = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Description>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
  <DialogPrimitive.Description
    ref={ref}
    className={cn("text-sm text-muted-foreground", className)}
    {...props}
  />
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

export {
  Dialog,
  DialogPortal,
  DialogOverlay,
  DialogTrigger,
  DialogClose,
  DialogContent,
  DialogHeader,
  DialogFooter,
  DialogTitle,
  DialogDescription,
}


================================================
FILE: src/components/ui/form.tsx
================================================
"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
  Controller,
  ControllerProps,
  FieldPath,
  FieldValues,
  FormProvider,
  useFormContext,
} from "react-hook-form"

import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"

const Form = FormProvider

type FormFieldContextValue<
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = {
  name: TName
}

const FormFieldContext = React.createContext<FormFieldContextValue>(
  {} as FormFieldContextValue
)

const FormField = <
  TFieldValues extends FieldValues = FieldValues,
  TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
>({
  ...props
}: ControllerProps<TFieldValues, TName>) => {
  return (
    <FormFieldContext.Provider value={{ name: props.name }}>
      <Controller {...props} />
    </FormFieldContext.Provider>
  )
}

const useFormField = () => {
  const fieldContext = React.useContext(FormFieldContext)
  const itemContext = React.useContext(FormItemContext)
  const { getFieldState, formState } = useFormContext()

  const fieldState = getFieldState(fieldContext.name, formState)

  if (!fieldContext) {
    throw new Error("useFormField should be used within <FormField>")
  }

  const { id } = itemContext

  return {
    id,
    name: fieldContext.name,
    formItemId: `${id}-form-item`,
    formDescriptionId: `${id}-form-item-description`,
    formMessageId: `${id}-form-item-message`,
    ...fieldState,
  }
}

type FormItemContextValue = {
  id: string
}

const FormItemContext = React.createContext<FormItemContextValue>(
  {} as FormItemContextValue
)

const FormItem = React.forwardRef<
  HTMLDivElement,
  React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
  const id = React.useId()

  return (
    <FormItemContext.Provider value={{ id }}>
      <div ref={ref} className={cn("space-y-2", className)} {...props} />
    </FormItemContext.Provider>
  )
})
FormItem.displayName = "FormItem"

const FormLabel = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
  const { error, formItemId } = useFormField()

  return (
    <Label
      ref={ref}
      className={cn(error && "text-destructive", className)}
      htmlFor={formItemId}
      {...props}
    />
  )
})
FormLabel.displayName = "FormLabel"

const FormControl = React.forwardRef<
  React.ElementRef<typeof Slot>,
  React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
  const { error, formItemId, formDescriptionId, formMessageId } = useFormField()

  return (
    <Slot
      ref={ref}
      id={formItemId}
      aria-describedby={
        !error
          ? `${formDescriptionId}`
          : `${formDescriptionId} ${formMessageId}`
      }
      aria-invalid={!!error}
      {...props}
    />
  )
})
FormControl.displayName = "FormControl"

const FormDescription = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
  const { formDescriptionId } = useFormField()

  return (
    <p
      ref={ref}
      id={formDescriptionId}
      className={cn("text-[0.8rem] text-muted-foreground", className)}
      {...props}
    />
  )
})
FormDescription.displayName = "FormDescription"

const FormMessage = React.forwardRef<
  HTMLParagraphElement,
  React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
  const { error, formMessageId } = useFormField()
  const body = error ? String(error?.message) : children

  if (!body) {
    return null
  }

  return (
    <p
      ref={ref}
      id={formMessageId}
      className={cn("text-[0.8rem] font-medium text-destructive", className)}
      {...props}
    >
      {body}
    </p>
  )
})
FormMessage.displayName = "FormMessage"

export {
  useFormField,
  Form,
  FormItem,
  FormLabel,
  FormControl,
  FormDescription,
  FormMessage,
  FormField,
}


================================================
FILE: src/components/ui/input.tsx
================================================
import * as React from "react"

import { cn } from "@/lib/utils"

const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
  ({ className, type, ...props }, ref) => {
    return (
      <input
        type={type}
        className={cn(
          "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",
          className
        )}
        ref={ref}
        {...props}
      />
    )
  }
)
Input.displayName = "Input"

export { Input }


================================================
FILE: src/components/ui/label.tsx
================================================
"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"

const labelVariants = cva(
  "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)

const Label = React.forwardRef<
  React.ElementRef<typeof LabelPrimitive.Root>,
  React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
    VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
  <LabelPrimitive.Root
    ref={ref}
    className={cn(labelVariants(), className)}
    {...props}
  />
))
Label.displayName = LabelPrimitive.Root.displayName

export { Label }


================================================
FILE: src/components/ui/popover.tsx
================================================
"use client"

import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"

import { cn } from "@/lib/utils"

const Popover = PopoverPrimitive.Root

const PopoverTrigger = PopoverPrimitive.Trigger

const PopoverAnchor = PopoverPrimitive.Anchor

const PopoverContent = React.forwardRef<
  React.ElementRef<typeof PopoverPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
  <PopoverPrimitive.Portal>
    <PopoverPrimitive.Content
      ref={ref}
      align={align}
      sideOffset={sideOffset}
      className={cn(
        "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",
        className
      )}
      {...props}
    />
  </PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName

export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }


================================================
FILE: src/components/ui/select.tsx
================================================
"use client"

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"

import { cn } from "@/lib/utils"

const Select = SelectPrimitive.Root

const SelectGroup = SelectPrimitive.Group

const SelectValue = SelectPrimitive.Value

const SelectTrigger = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Trigger
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  >
    {children}
    <SelectPrimitive.Icon asChild>
      <ChevronDown className="h-4 w-4 opacity-50" />
    </SelectPrimitive.Icon>
  </SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName

const SelectScrollUpButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollUpButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronUp className="h-4 w-4" />
  </SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName

const SelectScrollDownButton = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.ScrollDownButton
    ref={ref}
    className={cn(
      "flex cursor-default items-center justify-center py-1",
      className
    )}
    {...props}
  >
    <ChevronDown className="h-4 w-4" />
  </SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
  SelectPrimitive.ScrollDownButton.displayName

const SelectContent = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
  <SelectPrimitive.Portal>
    <SelectPrimitive.Content
      ref={ref}
      className={cn(
        "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",
        position === "popper" &&
          "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
        className
      )}
      position={position}
      {...props}
    >
      <SelectScrollUpButton />
      <SelectPrimitive.Viewport
        className={cn(
          "p-1",
          position === "popper" &&
            "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
        )}
      >
        {children}
      </SelectPrimitive.Viewport>
      <SelectScrollDownButton />
    </SelectPrimitive.Content>
  </SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName

const SelectLabel = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Label>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Label
    ref={ref}
    className={cn("px-2 py-1.5 text-sm font-semibold", className)}
    {...props}
  />
))
SelectLabel.displayName = SelectPrimitive.Label.displayName

const SelectItem = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Item>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
  <SelectPrimitive.Item
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  >
    <span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
      <SelectPrimitive.ItemIndicator>
        <Check className="h-4 w-4" />
      </SelectPrimitive.ItemIndicator>
    </span>
    <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
  </SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName

const SelectSeparator = React.forwardRef<
  React.ElementRef<typeof SelectPrimitive.Separator>,
  React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
  <SelectPrimitive.Separator
    ref={ref}
    className={cn("-mx-1 my-1 h-px bg-muted", className)}
    {...props}
  />
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName

export {
  Select,
  SelectGroup,
  SelectValue,
  SelectTrigger,
  SelectContent,
  SelectLabel,
  SelectItem,
  SelectSeparator,
  SelectScrollUpButton,
  SelectScrollDownButton,
}


================================================
FILE: src/components/ui/table.tsx
================================================
import * as React from "react"

import { cn } from "@/lib/utils"

const Table = React.forwardRef<
  HTMLTableElement,
  React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
  <div className="relative w-full overflow-auto">
    <table
      ref={ref}
      className={cn("w-full caption-bottom text-sm", className)}
      {...props}
    />
  </div>
))
Table.displayName = "Table"

const TableHeader = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"

const TableBody = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <tbody
    ref={ref}
    className={cn("[&_tr:last-child]:border-0", className)}
    {...props}
  />
))
TableBody.displayName = "TableBody"

const TableFooter = React.forwardRef<
  HTMLTableSectionElement,
  React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
  <tfoot
    ref={ref}
    className={cn(
      "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
      className
    )}
    {...props}
  />
))
TableFooter.displayName = "TableFooter"

const TableRow = React.forwardRef<
  HTMLTableRowElement,
  React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
  <tr
    ref={ref}
    className={cn(
      "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
      className
    )}
    {...props}
  />
))
TableRow.displayName = "TableRow"

const TableHead = React.forwardRef<
  HTMLTableCellElement,
  React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
  <th
    ref={ref}
    className={cn(
      "h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
      className
    )}
    {...props}
  />
))
TableHead.displayName = "TableHead"

const TableCell = React.forwardRef<
  HTMLTableCellElement,
  React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
  <td
    ref={ref}
    className={cn(
      "p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
      className
    )}
    {...props}
  />
))
TableCell.displayName = "TableCell"

const TableCaption = React.forwardRef<
  HTMLTableCaptionElement,
  React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
  <caption
    ref={ref}
    className={cn("mt-4 text-sm text-muted-foreground", className)}
    {...props}
  />
))
TableCaption.displayName = "TableCaption"

export {
  Table,
  TableHeader,
  TableBody,
  TableFooter,
  TableHead,
  TableRow,
  TableCell,
  TableCaption,
}


================================================
FILE: src/components/ui/tabs.tsx
================================================
"use client"

import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"

import { cn } from "@/lib/utils"

const Tabs = TabsPrimitive.Root

const TabsList = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.List>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.List
    ref={ref}
    className={cn(
      "inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
      className
    )}
    {...props}
  />
))
TabsList.displayName = TabsPrimitive.List.displayName

const TabsTrigger = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.Trigger>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.Trigger
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  />
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName

const TabsContent = React.forwardRef<
  React.ElementRef<typeof TabsPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
  <TabsPrimitive.Content
    ref={ref}
    className={cn(
      "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
      className
    )}
    {...props}
  />
))
TabsContent.displayName = TabsPrimitive.Content.displayName

export { Tabs, TabsList, TabsTrigger, TabsContent }


================================================
FILE: src/components/ui/textarea.tsx
================================================
import * as React from "react"

import { cn } from "@/lib/utils"

const Textarea = React.forwardRef<
  HTMLTextAreaElement,
  React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
  return (
    <textarea
      className={cn(
        "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",
        className
      )}
      ref={ref}
      {...props}
    />
  )
})
Textarea.displayName = "Textarea"

export { Textarea }


================================================
FILE: src/components/ui/toast.tsx
================================================
"use client"

import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"

import { cn } from "@/lib/utils"

const ToastProvider = ToastPrimitives.Provider

const ToastViewport = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Viewport>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Viewport
    ref={ref}
    className={cn(
      "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]",
      className
    )}
    {...props}
  />
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName

const toastVariants = cva(
  "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",
  {
    variants: {
      variant: {
        default: "border bg-background text-foreground",
        destructive:
          "destructive group border-destructive bg-destructive text-destructive-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
)

const Toast = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Root>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
    VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
  return (
    <ToastPrimitives.Root
      ref={ref}
      className={cn(toastVariants({ variant }), className)}
      {...props}
    />
  )
})
Toast.displayName = ToastPrimitives.Root.displayName

const ToastAction = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Action>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Action
    ref={ref}
    className={cn(
      "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",
      className
    )}
    {...props}
  />
))
ToastAction.displayName = ToastPrimitives.Action.displayName

const ToastClose = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Close>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Close
    ref={ref}
    className={cn(
      "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",
      className
    )}
    toast-close=""
    {...props}
  >
    <X className="h-4 w-4" />
  </ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName

const ToastTitle = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Title>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Title
    ref={ref}
    className={cn("text-sm font-semibold [&+div]:text-xs", className)}
    {...props}
  />
))
ToastTitle.displayName = ToastPrimitives.Title.displayName

const ToastDescription = React.forwardRef<
  React.ElementRef<typeof ToastPrimitives.Description>,
  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
  <ToastPrimitives.Description
    ref={ref}
    className={cn("text-sm opacity-90", className)}
    {...props}
  />
))
ToastDescription.displayName = ToastPrimitives.Description.displayName

type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>

type ToastActionElement = React.ReactElement<typeof ToastAction>

export {
  type ToastProps,
  type ToastActionElement,
  ToastProvider,
  ToastViewport,
  Toast,
  ToastTitle,
  ToastDescription,
  ToastClose,
  ToastAction,
}


================================================
FILE: src/components/ui/toaster.tsx
================================================
"use client"

import { useToast } from "@/hooks/use-toast"
import {
  Toast,
  ToastClose,
  ToastDescription,
  ToastProvider,
  ToastTitle,
  ToastViewport,
} from "@/components/ui/toast"

export function Toaster() {
  const { toasts } = useToast()

  return (
    <ToastProvider>
      {toasts.map(function ({ id, title, description, action, ...props }) {
        return (
          <Toast key={id} {...props}>
            <div className="grid gap-1">
              {title && <ToastTitle>{title}</ToastTitle>}
              {description && (
                <ToastDescription>{description}</ToastDescription>
              )}
            </div>
            {action}
            <ToastClose />
          </Toast>
        )
      })}
      <ToastViewport />
    </ToastProvider>
  )
}


================================================
FILE: src/data/env/client.ts
================================================
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  client: {
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
    NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),
    NEXT_PUBLIC_CLERK_SIGN_UP_URL: z.string().min(1),
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().min(1),
    NEXT_PUBLIC_SERVER_URL: z.string().min(1),
  },
  experimental__runtimeEnv: {
    NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
      process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
    NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
    NEXT_PUBLIC_CLERK_SIGN_UP_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_UP_URL,
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
      process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
    NEXT_PUBLIC_SERVER_URL: process.env.NEXT_PUBLIC_SERVER_URL,
  },
})


================================================
FILE: src/data/env/server.ts
================================================
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  server: {
    DB_PASSWORD: z.string().min(1),
    DB_USER: z.string().min(1),
    DB_NAME: z.string().min(1),
    DB_HOST: z.string().min(1),
    CLERK_SECRET_KEY: z.string().min(1),
    CLERK_WEBHOOK_SECRET: z.string().min(1),
    ARCJET_KEY: z.string().min(1),
    TEST_IP_ADDRESS: z.string().min(1).optional(),
    STRIPE_PPP_50_COUPON_ID: z.string().min(1),
    STRIPE_PPP_40_COUPON_ID: z.string().min(1),
    STRIPE_PPP_30_COUPON_ID: z.string().min(1),
    STRIPE_PPP_20_COUPON_ID: z.string().min(1),
    STRIPE_SECRET_KEY: z.string().min(1),
    STRIPE_WEBHOOK_SECRET: z.string().min(1),
  },
  experimental__runtimeEnv: process.env,
})


================================================
FILE: src/data/pppCoupons.ts
================================================
import { env } from "./env/server"

export const pppCoupons = [
  {
    stripeCouponId: env.STRIPE_PPP_50_COUPON_ID,
    discountPercentage: 0.5,
    countryCodes: [
      "AF",
      "EG",
      "IR",
      "KG",
      "LK",
      "BT",
      "LA",
      "LB",
      "LY",
      "MM",
      "PK",
      "SL",
      "TJ",
      "NP",
      "UZ",
      "SD",
      "IN",
      "MG",
      "TR",
      "AL",
      "BA",
      "CM",
      "BD",
      "BF",
      "BJ",
      "JO",
      "BI",
      "CO",
      "CI",
      "FJ",
      "ET",
      "GE",
      "KM",
      "LS",
      "KH",
      "AM",
      "BO",
      "BY",
      "DZ",
      "ER",
      "GH",
      "GM",
      "GW",
      "ID",
      "KE",
      "KZ",
      "MD",
      "MK",
      "ML",
      "MW",
      "MY",
      "MZ",
      "NG",
      "NI",
      "PH",
      "PY",
      "RW",
      "TH",
      "TZ",
      "UA",
      "UG",
      "VN",
      "MN",
      "MR",
      "MU",
      "SO",
      "TN",
      "ZM",
      "ME",
      "RO",
      "RS",
      "SN",
      "MA",
      "NE",
      "SR",
      "SZ",
      "TG",
      "EC",
      "BG",
      "HR",
      "BW",
      "AO",
      "AZ",
      "CF",
      "CV",
      "GY",
      "HU",
      "GQ",
      "HN",
      "BH",
      "CD",
      "DO",
      "GN",
      "LR",
      "PA",
      "NA",
      "PE",
      "PL",
      "SC",
      "SV",
      "TW",
      "MV",
      "TD",
      "YE",
      "ZA",
      "RU",
    ],
  },
  {
    stripeCouponId: env.STRIPE_PPP_40_COUPON_ID,
    discountPercentage: 0.4,
    countryCodes: [
      "GR",
      "KN",
      "AR",
      "BR",
      "CN",
      "DJ",
      "IQ",
      "JM",
      "GT",
      "LT",
      "CL",
      "CR",
      "CZ",
      "GA",
      "GD",
      "HT",
      "LV",
      "ST",
      "VC",
      "PT",
      "MX",
      "SA",
      "SI",
      "SK",
      "TM",
      "BN",
      "MO",
      "TL",
    ],
  },
  {
    stripeCouponId: env.STRIPE_PPP_30_COUPON_ID,
    discountPercentage: 0.3,
    countryCodes: [
      "AE",
      "ES",
      "AW",
      "CY",
      "EE",
      "IT",
      "KR",
      "BZ",
      "CG",
      "MT",
      "SG",
      "DM",
      "TO",
      "VE",
      "WS",
      "OM",
      "ZW",
    ],
  },
  {
    stripeCouponId: env.STRIPE_PPP_20_COUPON_ID,
    discountPercentage: 0.2,
    countryCodes: [
      "AT",
      "JP",
      "BE",
      "BS",
      "DE",
      "FR",
      "KI",
      "KW",
      "HK",
      "LC",
      "AG",
      "QA",
      "PG",
      "TT",
      "UY",
    ],
  },
]


================================================
FILE: src/data/typeOverrides/clerk.d.ts
================================================
import { UserRole } from "@/drizzle/schema"

export {}

declare global {
  interface CustomJwtSessionClaims {
    dbId?: string
    role?: UserRole
  }

  interface UserPublicMetadata {
    dbId?: string
    role?: UserRole
  }
}


================================================
FILE: src/drizzle/db.ts
================================================
import { env } from "@/data/env/server"
import { drizzle } from "drizzle-orm/node-postgres"
import * as schema from "./schema"

export const db = drizzle({
  schema,
  connection: {
    password: env.DB_PASSWORD,
    user: env.DB_USER,
    database: env.DB_NAME,
    host: env.DB_HOST,
  },
})


================================================
FILE: src/drizzle/migrations/0000_orange_wind_dancer.sql
================================================
CREATE TYPE "public"."course_section_status" AS ENUM('public', 'private');--> statement-breakpoint
CREATE TYPE "public"."lesson_status" AS ENUM('public', 'private', 'preview');--> statement-breakpoint
CREATE TYPE "public"."product_status" AS ENUM('public', 'private');--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin');--> statement-breakpoint
CREATE TABLE "courses" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"name" text NOT NULL,
	"description" text NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "course_products" (
	"courseId" uuid NOT NULL,
	"productId" uuid NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "course_products_courseId_productId_pk" PRIMARY KEY("courseId","productId")
);
--> statement-breakpoint
CREATE TABLE "course_sections" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"name" text NOT NULL,
	"status" "course_section_status" DEFAULT 'private' NOT NULL,
	"order" integer NOT NULL,
	"courseId" uuid NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "lessons" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"name" text NOT NULL,
	"description" text,
	"youtubeVideoId" text NOT NULL,
	"order" integer NOT NULL,
	"status" "lesson_status" DEFAULT 'private' NOT NULL,
	"sectionId" uuid NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "products" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"name" text NOT NULL,
	"description" text NOT NULL,
	"imageUrl" text NOT NULL,
	"priceInDollars" integer NOT NULL,
	"status" "product_status" DEFAULT 'private' NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "purchases" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"pricePaidInCents" integer NOT NULL,
	"productDetails" jsonb NOT NULL,
	"userId" uuid NOT NULL,
	"productId" uuid NOT NULL,
	"stripeSessionId" text NOT NULL,
	"refundedAt" timestamp with time zone,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "purchases_stripeSessionId_unique" UNIQUE("stripeSessionId")
);
--> statement-breakpoint
CREATE TABLE "users" (
	"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
	"clerkUserId" text NOT NULL,
	"email" text NOT NULL,
	"name" text NOT NULL,
	"role" "user_role" DEFAULT 'user' NOT NULL,
	"imageUrl" text,
	"deletedAt" timestamp with time zone,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "users_clerkUserId_unique" UNIQUE("clerkUserId")
);
--> statement-breakpoint
CREATE TABLE "user_course_access" (
	"userId" uuid NOT NULL,
	"courseId" uuid NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "user_course_access_userId_courseId_pk" PRIMARY KEY("userId","courseId")
);
--> statement-breakpoint
CREATE TABLE "user_lesson_complete" (
	"userId" uuid NOT NULL,
	"lessonId" uuid NOT NULL,
	"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
	"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
	CONSTRAINT "user_lesson_complete_userId_lessonId_pk" PRIMARY KEY("userId","lessonId")
);
--> statement-breakpoint
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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
ALTER 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;

================================================
FILE: src/drizzle/migrations/meta/0000_snapshot.json
================================================
{
  "id": "d4730870-f365-4597-857f-fb8168440798",
  "prevId": "00000000-0000-0000-0000-000000000000",
  "version": "7",
  "dialect": "postgresql",
  "tables": {
    "public.courses": {
      "name": "courses",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "description": {
          "name": "description",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {},
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.course_products": {
      "name": "course_products",
      "schema": "",
      "columns": {
        "courseId": {
          "name": "courseId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "productId": {
          "name": "productId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "course_products_courseId_courses_id_fk": {
          "name": "course_products_courseId_courses_id_fk",
          "tableFrom": "course_products",
          "tableTo": "courses",
          "columnsFrom": [
            "courseId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "restrict",
          "onUpdate": "no action"
        },
        "course_products_productId_products_id_fk": {
          "name": "course_products_productId_products_id_fk",
          "tableFrom": "course_products",
          "tableTo": "products",
          "columnsFrom": [
            "productId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {
        "course_products_courseId_productId_pk": {
          "name": "course_products_courseId_productId_pk",
          "columns": [
            "courseId",
            "productId"
          ]
        }
      },
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.course_sections": {
      "name": "course_sections",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "status": {
          "name": "status",
          "type": "course_section_status",
          "typeSchema": "public",
          "primaryKey": false,
          "notNull": true,
          "default": "'private'"
        },
        "order": {
          "name": "order",
          "type": "integer",
          "primaryKey": false,
          "notNull": true
        },
        "courseId": {
          "name": "courseId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "course_sections_courseId_courses_id_fk": {
          "name": "course_sections_courseId_courses_id_fk",
          "tableFrom": "course_sections",
          "tableTo": "courses",
          "columnsFrom": [
            "courseId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.lessons": {
      "name": "lessons",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "description": {
          "name": "description",
          "type": "text",
          "primaryKey": false,
          "notNull": false
        },
        "youtubeVideoId": {
          "name": "youtubeVideoId",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "order": {
          "name": "order",
          "type": "integer",
          "primaryKey": false,
          "notNull": true
        },
        "status": {
          "name": "status",
          "type": "lesson_status",
          "typeSchema": "public",
          "primaryKey": false,
          "notNull": true,
          "default": "'private'"
        },
        "sectionId": {
          "name": "sectionId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "lessons_sectionId_course_sections_id_fk": {
          "name": "lessons_sectionId_course_sections_id_fk",
          "tableFrom": "lessons",
          "tableTo": "course_sections",
          "columnsFrom": [
            "sectionId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.products": {
      "name": "products",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "description": {
          "name": "description",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "imageUrl": {
          "name": "imageUrl",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "priceInDollars": {
          "name": "priceInDollars",
          "type": "integer",
          "primaryKey": false,
          "notNull": true
        },
        "status": {
          "name": "status",
          "type": "product_status",
          "typeSchema": "public",
          "primaryKey": false,
          "notNull": true,
          "default": "'private'"
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {},
      "compositePrimaryKeys": {},
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.purchases": {
      "name": "purchases",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "pricePaidInCents": {
          "name": "pricePaidInCents",
          "type": "integer",
          "primaryKey": false,
          "notNull": true
        },
        "productDetails": {
          "name": "productDetails",
          "type": "jsonb",
          "primaryKey": false,
          "notNull": true
        },
        "userId": {
          "name": "userId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "productId": {
          "name": "productId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "stripeSessionId": {
          "name": "stripeSessionId",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "refundedAt": {
          "name": "refundedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": false
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "purchases_userId_users_id_fk": {
          "name": "purchases_userId_users_id_fk",
          "tableFrom": "purchases",
          "tableTo": "users",
          "columnsFrom": [
            "userId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "restrict",
          "onUpdate": "no action"
        },
        "purchases_productId_products_id_fk": {
          "name": "purchases_productId_products_id_fk",
          "tableFrom": "purchases",
          "tableTo": "products",
          "columnsFrom": [
            "productId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "restrict",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {},
      "uniqueConstraints": {
        "purchases_stripeSessionId_unique": {
          "name": "purchases_stripeSessionId_unique",
          "nullsNotDistinct": false,
          "columns": [
            "stripeSessionId"
          ]
        }
      },
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.users": {
      "name": "users",
      "schema": "",
      "columns": {
        "id": {
          "name": "id",
          "type": "uuid",
          "primaryKey": true,
          "notNull": true,
          "default": "gen_random_uuid()"
        },
        "clerkUserId": {
          "name": "clerkUserId",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "email": {
          "name": "email",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "name": {
          "name": "name",
          "type": "text",
          "primaryKey": false,
          "notNull": true
        },
        "role": {
          "name": "role",
          "type": "user_role",
          "typeSchema": "public",
          "primaryKey": false,
          "notNull": true,
          "default": "'user'"
        },
        "imageUrl": {
          "name": "imageUrl",
          "type": "text",
          "primaryKey": false,
          "notNull": false
        },
        "deletedAt": {
          "name": "deletedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": false
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {},
      "compositePrimaryKeys": {},
      "uniqueConstraints": {
        "users_clerkUserId_unique": {
          "name": "users_clerkUserId_unique",
          "nullsNotDistinct": false,
          "columns": [
            "clerkUserId"
          ]
        }
      },
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.user_course_access": {
      "name": "user_course_access",
      "schema": "",
      "columns": {
        "userId": {
          "name": "userId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "courseId": {
          "name": "courseId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "user_course_access_userId_users_id_fk": {
          "name": "user_course_access_userId_users_id_fk",
          "tableFrom": "user_course_access",
          "tableTo": "users",
          "columnsFrom": [
            "userId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        },
        "user_course_access_courseId_courses_id_fk": {
          "name": "user_course_access_courseId_courses_id_fk",
          "tableFrom": "user_course_access",
          "tableTo": "courses",
          "columnsFrom": [
            "courseId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {
        "user_course_access_userId_courseId_pk": {
          "name": "user_course_access_userId_courseId_pk",
          "columns": [
            "userId",
            "courseId"
          ]
        }
      },
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    },
    "public.user_lesson_complete": {
      "name": "user_lesson_complete",
      "schema": "",
      "columns": {
        "userId": {
          "name": "userId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "lessonId": {
          "name": "lessonId",
          "type": "uuid",
          "primaryKey": false,
          "notNull": true
        },
        "createdAt": {
          "name": "createdAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        },
        "updatedAt": {
          "name": "updatedAt",
          "type": "timestamp with time zone",
          "primaryKey": false,
          "notNull": true,
          "default": "now()"
        }
      },
      "indexes": {},
      "foreignKeys": {
        "user_lesson_complete_userId_users_id_fk": {
          "name": "user_lesson_complete_userId_users_id_fk",
          "tableFrom": "user_lesson_complete",
          "tableTo": "users",
          "columnsFrom": [
            "userId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        },
        "user_lesson_complete_lessonId_lessons_id_fk": {
          "name": "user_lesson_complete_lessonId_lessons_id_fk",
          "tableFrom": "user_lesson_complete",
          "tableTo": "lessons",
          "columnsFrom": [
            "lessonId"
          ],
          "columnsTo": [
            "id"
          ],
          "onDelete": "cascade",
          "onUpdate": "no action"
        }
      },
      "compositePrimaryKeys": {
        "user_lesson_complete_userId_lessonId_pk": {
          "name": "user_lesson_complete_userId_lessonId_pk",
          "columns": [
            "userId",
            "lessonId"
          ]
        }
      },
      "uniqueConstraints": {},
      "policies": {},
      "checkConstraints": {},
      "isRLSEnabled": false
    }
  },
  "enums": {
    "public.course_section_status": {
      "name": "course_section_status",
      "schema": "public",
      "values": [
        "public",
        "private"
      ]
    },
    "public.lesson_status": {
      "name": "lesson_status",
      "schema": "public",
      "values": [
        "public",
        "private",
        "preview"
      ]
    },
    "public.product_status": {
      "name": "product_status",
      "schema": "public",
      "values": [
        "public",
        "private"
      ]
    },
    "public.user_role": {
      "name": "user_role",
      "schema": "public",
      "values": [
        "user",
        "admin"
      ]
    }
  },
  "schemas": {},
  "sequences": {},
  "roles": {},
  "policies": {},
  "views": {},
  "_meta": {
    "columns": {},
    "schemas": {},
    "tables": {}
  }
}

================================================
FILE: src/drizzle/migrations/meta/_journal.json
================================================
{
  "version": "7",
  "dialect": "postgresql",
  "entries": [
    {
      "idx": 0,
      "version": "7",
      "when": 1736952636321,
      "tag": "0000_orange_wind_dancer",
      "breakpoints": true
    }
  ]
}

================================================
FILE: src/drizzle/schema/course.ts
================================================
import { relations } from "drizzle-orm"
import { pgTable, text } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseProductTable } from "./courseProduct"
import { UserCourseAccessTable } from "./userCourseAccess"
import { CourseSectionTable } from "./courseSection"

export const CourseTable = pgTable("courses", {
  id,
  name: text().notNull(),
  description: text().notNull(),
  createdAt,
  updatedAt,
})

export const CourseRelationships = relations(CourseTable, ({ many }) => ({
  courseProducts: many(CourseProductTable),
  userCourseAccesses: many(UserCourseAccessTable),
  courseSections: many(CourseSectionTable),
}))


================================================
FILE: src/drizzle/schema/courseProduct.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { CourseTable } from "./course"
import { ProductTable } from "./product"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"

export const CourseProductTable = pgTable(
  "course_products",
  {
    courseId: uuid()
      .notNull()
      .references(() => CourseTable.id, { onDelete: "restrict" }),
    productId: uuid()
      .notNull()
      .references(() => ProductTable.id, { onDelete: "cascade" }),
    createdAt,
    updatedAt,
  },
  t => [primaryKey({ columns: [t.courseId, t.productId] })]
)

export const CourseProductRelationships = relations(
  CourseProductTable,
  ({ one }) => ({
    course: one(CourseTable, {
      fields: [CourseProductTable.courseId],
      references: [CourseTable.id],
    }),
    product: one(ProductTable, {
      fields: [CourseProductTable.productId],
      references: [ProductTable.id],
    }),
  })
)


================================================
FILE: src/drizzle/schema/courseSection.ts
================================================
import { integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseTable } from "./course"
import { relations } from "drizzle-orm"
import { LessonTable } from "./lesson"

export const courseSectionStatuses = ["public", "private"] as const
export type CourseSectionStatus = (typeof courseSectionStatuses)[number]
export const courseSectionStatusEnum = pgEnum(
  "course_section_status",
  courseSectionStatuses
)

export const CourseSectionTable = pgTable("course_sections", {
  id,
  name: text().notNull(),
  status: courseSectionStatusEnum().notNull().default("private"),
  order: integer().notNull(),
  courseId: uuid()
    .notNull()
    .references(() => CourseTable.id, { onDelete: "cascade" }),
  createdAt,
  updatedAt,
})

export const CourseSectionRelationships = relations(
  CourseSectionTable,
  ({ many, one }) => ({
    course: one(CourseTable, {
      fields: [CourseSectionTable.courseId],
      references: [CourseTable.id],
    }),
    lessons: many(LessonTable),
  })
)


================================================
FILE: src/drizzle/schema/lesson.ts
================================================
import { pgTable, text, uuid, integer, pgEnum } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { CourseSectionTable } from "./courseSection"
import { UserLessonCompleteTable } from "./userLessonComplete"

export const lessonStatuses = ["public", "private", "preview"] as const
export type LessonStatus = (typeof lessonStatuses)[number]
export const lessonStatusEnum = pgEnum("lesson_status", lessonStatuses)

export const LessonTable = pgTable("lessons", {
  id,
  name: text().notNull(),
  description: text(),
  youtubeVideoId: text().notNull(),
  order: integer().notNull(),
  status: lessonStatusEnum().notNull().default("private"),
  sectionId: uuid()
    .notNull()
    .references(() => CourseSectionTable.id, { onDelete: "cascade" }),
  createdAt,
  updatedAt,
})

export const LessonRelationships = relations(LessonTable, ({ one, many }) => ({
  section: one(CourseSectionTable, {
    fields: [LessonTable.sectionId],
    references: [CourseSectionTable.id],
  }),
  userLessonsComplete: many(UserLessonCompleteTable),
}))


================================================
FILE: src/drizzle/schema/product.ts
================================================
import { relations } from "drizzle-orm"
import { pgTable, text, integer, pgEnum } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { CourseProductTable } from "./courseProduct"

export const productStatuses = ["public", "private"] as const
export type ProductStatus = (typeof productStatuses)[number]
export const productStatusEnum = pgEnum("product_status", productStatuses)

export const ProductTable = pgTable("products", {
  id,
  name: text().notNull(),
  description: text().notNull(),
  imageUrl: text().notNull(),
  priceInDollars: integer().notNull(),
  status: productStatusEnum().notNull().default("private"),
  createdAt,
  updatedAt,
})

export const ProductRelationships = relations(ProductTable, ({ many }) => ({
  courseProducts: many(CourseProductTable),
}))


================================================
FILE: src/drizzle/schema/purchase.ts
================================================
import {
  pgTable,
  integer,
  jsonb,
  uuid,
  text,
  timestamp,
} from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { ProductTable } from "./product"

export const PurchaseTable = pgTable("purchases", {
  id,
  pricePaidInCents: integer().notNull(),
  productDetails: jsonb()
    .notNull()
    .$type<{ name: string; description: string; imageUrl: string }>(),
  userId: uuid()
    .notNull()
    .references(() => UserTable.id, { onDelete: "restrict" }),
  productId: uuid()
    .notNull()
    .references(() => ProductTable.id, { onDelete: "restrict" }),
  stripeSessionId: text().notNull().unique(),
  refundedAt: timestamp({ withTimezone: true }),
  createdAt,
  updatedAt,
})

export const PurchaseRelationships = relations(PurchaseTable, ({ one }) => ({
  user: one(UserTable, {
    fields: [PurchaseTable.userId],
    references: [UserTable.id],
  }),
  product: one(ProductTable, {
    fields: [PurchaseTable.productId],
    references: [ProductTable.id],
  }),
}))


================================================
FILE: src/drizzle/schema/user.ts
================================================
import { pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"
import { createdAt, id, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserCourseAccessTable } from "./userCourseAccess"

export const userRoles = ["user", "admin"] as const
export type UserRole = (typeof userRoles)[number]
export const userRoleEnum = pgEnum("user_role", userRoles)

export const UserTable = pgTable("users", {
  id,
  clerkUserId: text().notNull().unique(),
  email: text().notNull(),
  name: text().notNull(),
  role: userRoleEnum().notNull().default("user"),
  imageUrl: text(),
  deletedAt: timestamp({ withTimezone: true }),
  createdAt,
  updatedAt,
})

export const UserRelationships = relations(UserTable, ({ many }) => ({
  userCourseAccesses: many(UserCourseAccessTable),
}))


================================================
FILE: src/drizzle/schema/userCourseAccess.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { CourseTable } from "./course"

export const UserCourseAccessTable = pgTable(
  "user_course_access",
  {
    userId: uuid()
      .notNull()
      .references(() => UserTable.id, { onDelete: "cascade" }),
    courseId: uuid()
      .notNull()
      .references(() => CourseTable.id, { onDelete: "cascade" }),
    createdAt,
    updatedAt,
  },
  t => [primaryKey({ columns: [t.userId, t.courseId] })]
)

export const UserCourseAccessRelationships = relations(
  UserCourseAccessTable,
  ({ one }) => ({
    user: one(UserTable, {
      fields: [UserCourseAccessTable.userId],
      references: [UserTable.id],
    }),
    course: one(CourseTable, {
      fields: [UserCourseAccessTable.courseId],
      references: [CourseTable.id],
    }),
  })
)


================================================
FILE: src/drizzle/schema/userLessonComplete.ts
================================================
import { pgTable, primaryKey, uuid } from "drizzle-orm/pg-core"
import { createdAt, updatedAt } from "../schemaHelpers"
import { relations } from "drizzle-orm"
import { UserTable } from "./user"
import { LessonTable } from "./lesson"

export const UserLessonCompleteTable = pgTable(
  "user_lesson_complete",
  {
    userId: uuid()
      .notNull()
      .references(() => UserTable.id, { onDelete: "cascade" }),
    lessonId: uuid()
      .notNull()
      .references(() => LessonTable.id, { onDelete: "cascade" }),
    createdAt,
    updatedAt,
  },
  t => [primaryKey({ columns: [t.userId, t.lessonId] })]
)

export const UserLessonCompleteRelationships = relations(
  UserLessonCompleteTable,
  ({ one }) => ({
    user: one(UserTable, {
      fields: [UserLessonCompleteTable.userId],
      references: [UserTable.id],
    }),
    lesson: one(LessonTable, {
      fields: [UserLessonCompleteTable.lessonId],
      references: [LessonTable.id],
    }),
  })
)


================================================
FILE: src/drizzle/schema.ts
================================================
export * from "./schema/course"
export * from "./schema/courseProduct"
export * from "./schema/courseSection"
export * from "./schema/lesson"
export * from "./schema/product"
export * from "./schema/purchase"
export * from "./schema/user"
export * from "./schema/userCourseAccess"
export * from "./schema/userLessonComplete"


================================================
FILE: src/drizzle/schemaHelpers.ts
================================================
import { timestamp, uuid } from "drizzle-orm/pg-core"

export const id = uuid().primaryKey().defaultRandom()
export const createdAt = timestamp({ withTimezone: true })
  .notNull()
  .defaultNow()
export const updatedAt = timestamp({ withTimezone: true })
  .notNull()
  .defaultNow()
  .$onUpdate(() => new Date())


================================================
FILE: src/features/courseSections/actions/sections.ts
================================================
"use server"

import { z } from "zod"
import { getCurrentUser } from "@/services/clerk"
import { sectionSchema } from "../schemas/sections"
import {
  canCreateCourseSections,
  canDeleteCourseSections,
  canUpdateCourseSections,
} from "../permissions/sections"
import {
  getNextCourseSectionOrder,
  insertSection,
  updateSection as updateSectionDb,
  deleteSection as deleteSectionDb,
  updateSectionOrders as updateSectionOrdersDb,
} from "../db/sections"

export async function createSection(
  courseId: string,
  unsafeData: z.infer<typeof sectionSchema>
) {
  const { success, data } = sectionSchema.safeParse(unsafeData)

  if (!success || !canCreateCourseSections(await getCurrentUser())) {
    return { error: true, message: "There was an error creating your section" }
  }

  const order = await getNextCourseSectionOrder(courseId)

  await insertSection({ ...data, courseId, order })

  return { error: false, message: "Successfully created your section" }
}

export async function updateSection(
  id: string,
  unsafeData: z.infer<typeof sectionSchema>
) {
  const { success, data } = sectionSchema.safeParse(unsafeData)

  if (!success || !canUpdateCourseSections(await getCurrentUser())) {
    return { error: true, message: "There was an error updating your section" }
  }

  await updateSectionDb(id, data)

  return { error: false, message: "Successfully updated your section" }
}

export async function deleteSection(id: string) {
  if (!canDeleteCourseSections(await getCurrentUser())) {
    return { error: true, message: "Error deleting your section" }
  }

  await deleteSectionDb(id)

  return { error: false, message: "Successfully deleted your section" }
}

export async function updateSectionOrders(sectionIds: string[]) {
  if (
    sectionIds.length === 0 ||
    !canUpdateCourseSections(await getCurrentUser())
  ) {
    return { error: true, message: "Error reordering your sections" }
  }

  await updateSectionOrdersDb(sectionIds)

  return { error: false, message: "Successfully reordered your sections" }
}


================================================
FILE: src/features/courseSections/components/SectionForm.tsx
================================================
"use client"

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { sectionSchema } from "../schemas/sections"
import { z } from "zod"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { actionToast } from "@/hooks/use-toast"
import { CourseSectionStatus, courseSectionStatuses } from "@/drizzle/schema"
import {
  Select,
  SelectItem,
  SelectTrigger,
  SelectValue,
  SelectContent,
} from "@/components/ui/select"
import { createSection, updateSection } from "../actions/sections"

export function SectionForm({
  section,
  courseId,
  onSuccess,
}: {
  section?: {
    id: string
    name: string
    status: CourseSectionStatus
  }
  courseId: string
  onSuccess?: () => void
}) {
  const form = useForm<z.infer<typeof sectionSchema>>({
    resolver: zodResolver(sectionSchema),
    defaultValues: section ?? {
      name: "",
      status: "public",
    },
  })

  async function onSubmit(values: z.infer<typeof sectionSchema>) {
    const action =
      section == null
        ? createSection.bind(null, courseId)
        : updateSection.bind(null, section.id)
    const data = await action(values)
    actionToast({ actionData: data })
    if (!data.error) onSuccess?.()
  }

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex gap-6 flex-col @container"
      >
        <div className="grid grid-cols-1 @lg:grid-cols-2 gap-6">
          <FormField
            control={form.control}
            name="name"
            render={({ field }) => (
              <FormItem>
                <FormLabel>
                  <RequiredLabelIcon />
                  Name
                </FormLabel>
                <FormControl>
                  <Input {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          <FormField
            control={form.control}
            name="status"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Status</FormLabel>
                <Select
                  onValueChange={field.onChange}
                  defaultValue={field.value}
                >
                  <FormControl>
                    <SelectTrigger>
                      <SelectValue />
                    </SelectTrigger>
                  </FormControl>
                  <SelectContent>
                    {courseSectionStatuses.map(status => (
                      <SelectItem key={status} value={status}>
                        {status}
                      </SelectItem>
                    ))}
                  </SelectContent>
                </Select>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
        <div className="self-end">
          <Button disabled={form.formState.isSubmitting} type="submit">
            Save
          </Button>
        </div>
      </form>
    </Form>
  )
}


================================================
FILE: src/features/courseSections/components/SectionFormDialog.tsx
================================================
"use client"

import {
  Dialog,
  DialogHeader,
  DialogTitle,
  DialogContent,
} from "@/components/ui/dialog"
import { CourseSectionStatus } from "@/drizzle/schema"
import { ReactNode, useState } from "react"
import { SectionForm } from "./SectionForm"

export function SectionFormDialog({
  courseId,
  section,
  children,
}: {
  courseId: string
  children: ReactNode
  section?: { id: string; name: string; status: CourseSectionStatus }
}) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <Dialog open={isOpen} onOpenChange={setIsOpen}>
      {children}
      <DialogContent>
        <DialogHeader>
          <DialogTitle>
            {section == null ? "New Section" : `Edit ${section.name}`}
          </DialogTitle>
        </DialogHeader>
        <div className="mt-4">
          <SectionForm
            section={section}
            courseId={courseId}
            onSuccess={() => setIsOpen(false)}
          />
        </div>
      </DialogContent>
    </Dialog>
  )
}


================================================
FILE: src/features/courseSections/components/SortableSectionList.tsx
================================================
"use client"

import { SortableItem, SortableList } from "@/components/SortableList"
import { CourseSectionStatus } from "@/drizzle/schema"
import { cn } from "@/lib/utils"
import { EyeClosed, Trash2Icon } from "lucide-react"
import { SectionFormDialog } from "./SectionFormDialog"
import { Button } from "@/components/ui/button"
import { ActionButton } from "@/components/ActionButton"
import { deleteSection, updateSectionOrders } from "../actions/sections"
import { DialogTrigger } from "@/components/ui/dialog"

export function SortableSectionList({
  courseId,
  sections,
}: {
  courseId: string
  sections: {
    id: string
    name: string
    status: CourseSectionStatus
  }[]
}) {
  return (
    <SortableList items={sections} onOrderChange={updateSectionOrders}>
      {items =>
        items.map(section => (
          <SortableItem
            key={section.id}
            id={section.id}
            className="flex items-center gap-1"
          >
            <div
              className={cn(
                "contents",
                section.status === "private" && "text-muted-foreground"
              )}
            >
              {section.status === "private" && <EyeClosed className="size-4" />}
              {section.name}
            </div>
            <SectionFormDialog section={section} courseId={courseId}>
              <DialogTrigger asChild>
                <Button variant="outline" size="sm" className="ml-auto">
                  Edit
                </Button>
              </DialogTrigger>
            </SectionFormDialog>
            <ActionButton
              action={deleteSection.bind(null, section.id)}
              requireAreYouSure
              variant="destructiveOutline"
              size="sm"
            >
              <Trash2Icon />
              <span className="sr-only">Delete</span>
            </ActionButton>
          </SortableItem>
        ))
      }
    </SortableList>
  )
}


================================================
FILE: src/features/courseSections/db/cache.ts
================================================
import { getCourseTag, getGlobalTag, getIdTag } from "@/lib/dataCache"
import { revalidateTag } from "next/cache"

export function getCourseSectionGlobalTag() {
  return getGlobalTag("courseSections")
}

export function getCourseSectionIdTag(id: string) {
  return getIdTag("courseSections", id)
}

export function getCourseSectionCourseTag(courseId: string) {
  return getCourseTag("courseSections", courseId)
}

export function revalidateCourseSectionCache({
  id,
  courseId,
}: {
  id: string
  courseId: string
}) {
  revalidateTag(getCourseSectionGlobalTag())
  revalidateTag(getCourseSectionIdTag(id))
  revalidateTag(getCourseSectionCourseTag(courseId))
}


================================================
FILE: src/features/courseSections/db/sections.ts
================================================
import { CourseSectionTable } from "@/drizzle/schema"
import { revalidateCourseSectionCache } from "./cache"
import { db } from "@/drizzle/db"
import { eq } from "drizzle-orm"

export async function getNextCourseSectionOrder(courseId: string) {
  const section = await db.query.CourseSectionTable.findFirst({
    columns: { order: true },
    where: ({ courseId: courseIdCol }, { eq }) => eq(courseIdCol, courseId),
    orderBy: ({ order }, { desc }) => desc(order),
  })

  return section ? section.order + 1 : 0
}

export async function insertSection(
  data: typeof CourseSectionTable.$inferInsert
) {
  const [newSection] = await db
    .insert(CourseSectionTable)
    .values(data)
    .returning()
  if (newSection == null) throw new Error("Failed to create section")

  revalidateCourseSectionCache({
    courseId: newSection.courseId,
    id: newSection.id,
  })

  return newSection
}

export async function updateSection(
  id: string,
  data: Partial<typeof CourseSectionTable.$inferInsert>
) {
  const [updatedSection] = await db
    .update(CourseSectionTable)
    .set(data)
    .where(eq(CourseSectionTable.id, id))
    .returning()
  if (updatedSection == null) throw new Error("Failed to update section")

  revalidateCourseSectionCache({
    courseId: updatedSection.courseId,
    id: updatedSection.id,
  })

  return updatedSection
}

export async function deleteSection(id: string) {
  const [deletedSection] = await db
    .delete(CourseSectionTable)
    .where(eq(CourseSectionTable.id, id))
    .returning()
  if (deletedSection == null) throw new Error("Failed to delete section")

  revalidateCourseSectionCache({
    courseId: deletedSection.courseId,
    id: deletedSection.id,
  })

  return deletedSection
}

export async function updateSectionOrders(sectionIds: string[]) {
  const sections = await Promise.all(
    sectionIds.map((id, index) =>
      db
        .update(CourseSectionTable)
        .set({ order: index })
        .where(eq(CourseSectionTable.id, id))
        .returning({
          courseId: CourseSectionTable.courseId,
          id: CourseSectionTable.id,
        })
    )
  )

  sections.flat().forEach(({ id, courseId }) => {
    revalidateCourseSectionCache({
      courseId,
      id,
    })
  })
}


================================================
FILE: src/features/courseSections/permissions/sections.ts
================================================
import { CourseSectionTable, UserRole } from "@/drizzle/schema"
import { eq } from "drizzle-orm"

export function canCreateCourseSections({
  role,
}: {
  role: UserRole | undefined
}) {
  return role === "admin"
}

export function canUpdateCourseSections({
  role,
}: {
  role: UserRole | undefined
}) {
  return role === "admin"
}

export function canDeleteCourseSections({
  role,
}: {
  role: UserRole | undefined
}) {
  return role === "admin"
}

export const wherePublicCourseSections = eq(CourseSectionTable.status, "public")


================================================
FILE: src/features/courseSections/schemas/sections.ts
================================================
import { courseSectionStatuses } from "@/drizzle/schema"
import { z } from "zod"

export const sectionSchema = z.object({
  name: z.string().min(1, "Required"),
  status: z.enum(courseSectionStatuses),
})


================================================
FILE: src/features/courses/actions/courses.ts
================================================
"use server"

import { z } from "zod"
import { courseSchema } from "../schemas/courses"
import { redirect } from "next/navigation"
import { getCurrentUser } from "@/services/clerk"
import {
  canCreateCourses,
  canDeleteCourses,
  canUpdateCourses,
} from "../permissions/courses"
import {
  insertCourse,
  deleteCourse as deleteCourseDB,
  updateCourse as updateCourseDb,
} from "../db/courses"

export async function createCourse(unsafeData: z.infer<typeof courseSchema>) {
  const { success, data } = courseSchema.safeParse(unsafeData)

  if (!success || !canCreateCourses(await getCurrentUser())) {
    return { error: true, message: "There was an error creating your course" }
  }

  const course = await insertCourse(data)

  redirect(`/admin/courses/${course.id}/edit`)
}

export async function updateCourse(
  id: string,
  unsafeData: z.infer<typeof courseSchema>
) {
  const { success, data } = courseSchema.safeParse(unsafeData)

  if (!success || !canUpdateCourses(await getCurrentUser())) {
    return { error: true, message: "There was an error updating your course" }
  }

  await updateCourseDb(id, data)

  return { error: false, message: "Successfully updated your course" }
}

export async function deleteCourse(id: string) {
  if (!canDeleteCourses(await getCurrentUser())) {
    return { error: true, message: "Error deleting your course" }
  }

  await deleteCourseDB(id)

  return { error: false, message: "Successfully deleted your course" }
}


================================================
FILE: src/features/courses/components/CourseForm.tsx
================================================
"use client"

import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { courseSchema } from "../schemas/courses"
import { z } from "zod"
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form"
import { RequiredLabelIcon } from "@/components/RequiredLabelIcon"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Button } from "@/components/ui/button"
import { createCourse, updateCourse } from "../actions/courses"
import { actionToast } from "@/hooks/use-toast"

export function CourseForm({
  course,
}: {
  course?: {
    id: string
    name: string
    description: string
  }
}) {
  const form = useForm<z.infer<typeof courseSchema>>({
    resolver: zodResolver(courseSchema),
    defaultValues: course ?? {
      name: "",
      description: "",
    },
  })

  async function onSubmit(values: z.infer<typeof courseSchema>) {
    const action =
      course == null ? createCourse : updateCourse.bind(null, course.id)
    const data = await action(values)
    actionToast({ actionData: data })
  }

  return (
    <Form {...form}>
      <form
        onSubmit={form.handleSubmit(onSubmit)}
        className="flex gap-6 flex-col"
      >
        <FormField
          control={form.control}
          name="name"
          render={({ field }) => (
            <FormItem>
              <FormLabel>
                <RequiredLabelIcon />
                Name
              </FormLabel>
              <FormControl>
                <Input {...field} />
              </FormControl>
Download .txt
gitextract_7h_8njrb/

├── .gitignore
├── LICENSE
├── README.md
├── components.json
├── docker-compose.yml
├── drizzle.config.ts
├── eslint.config.mjs
├── next.config.ts
├── package.json
├── postcss.config.mjs
├── src/
│   ├── app/
│   │   ├── (auth)/
│   │   │   ├── layout.tsx
│   │   │   ├── sign-in/
│   │   │   │   └── [[...sign-in]]/
│   │   │   │       └── page.tsx
│   │   │   └── sign-up/
│   │   │       └── [[...sign-up]]/
│   │   │           └── page.tsx
│   │   ├── (consumer)/
│   │   │   ├── courses/
│   │   │   │   ├── [courseId]/
│   │   │   │   │   ├── _client.tsx
│   │   │   │   │   ├── layout.tsx
│   │   │   │   │   ├── lessons/
│   │   │   │   │   │   └── [lessonId]/
│   │   │   │   │   │       └── page.tsx
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx
│   │   │   ├── products/
│   │   │   │   ├── [productId]/
│   │   │   │   │   ├── page.tsx
│   │   │   │   │   └── purchase/
│   │   │   │   │       ├── page.tsx
│   │   │   │   │       └── success/
│   │   │   │   │           └── page.tsx
│   │   │   │   └── purchase-failure/
│   │   │   │       └── page.tsx
│   │   │   └── purchases/
│   │   │       ├── [purchaseId]/
│   │   │       │   └── page.tsx
│   │   │       └── page.tsx
│   │   ├── admin/
│   │   │   ├── courses/
│   │   │   │   ├── [courseId]/
│   │   │   │   │   └── edit/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   ├── layout.tsx
│   │   │   ├── page.tsx
│   │   │   ├── products/
│   │   │   │   ├── [productId]/
│   │   │   │   │   └── edit/
│   │   │   │   │       └── page.tsx
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx
│   │   │   │   └── page.tsx
│   │   │   └── sales/
│   │   │       └── page.tsx
│   │   ├── api/
│   │   │   ├── clerk/
│   │   │   │   └── syncUsers/
│   │   │   │       └── route.ts
│   │   │   └── webhooks/
│   │   │       ├── clerk/
│   │   │       │   └── route.ts
│   │   │       └── stripe/
│   │   │           └── route.ts
│   │   ├── globals.css
│   │   └── layout.tsx
│   ├── components/
│   │   ├── ActionButton.tsx
│   │   ├── LoadingSpinner.tsx
│   │   ├── PageHeader.tsx
│   │   ├── RequiredLabelIcon.tsx
│   │   ├── Skeleton.tsx
│   │   ├── SortableList.tsx
│   │   └── ui/
│   │       ├── accordion.tsx
│   │       ├── alert-dialog.tsx
│   │       ├── badge.tsx
│   │       ├── button.tsx
│   │       ├── card.tsx
│   │       ├── command.tsx
│   │       ├── custom/
│   │       │   └── multi-select.tsx
│   │       ├── dialog.tsx
│   │       ├── form.tsx
│   │       ├── input.tsx
│   │       ├── label.tsx
│   │       ├── popover.tsx
│   │       ├── select.tsx
│   │       ├── table.tsx
│   │       ├── tabs.tsx
│   │       ├── textarea.tsx
│   │       ├── toast.tsx
│   │       └── toaster.tsx
│   ├── data/
│   │   ├── env/
│   │   │   ├── client.ts
│   │   │   └── server.ts
│   │   ├── pppCoupons.ts
│   │   └── typeOverrides/
│   │       └── clerk.d.ts
│   ├── drizzle/
│   │   ├── db.ts
│   │   ├── migrations/
│   │   │   ├── 0000_orange_wind_dancer.sql
│   │   │   └── meta/
│   │   │       ├── 0000_snapshot.json
│   │   │       └── _journal.json
│   │   ├── schema/
│   │   │   ├── course.ts
│   │   │   ├── courseProduct.ts
│   │   │   ├── courseSection.ts
│   │   │   ├── lesson.ts
│   │   │   ├── product.ts
│   │   │   ├── purchase.ts
│   │   │   ├── user.ts
│   │   │   ├── userCourseAccess.ts
│   │   │   └── userLessonComplete.ts
│   │   ├── schema.ts
│   │   └── schemaHelpers.ts
│   ├── features/
│   │   ├── courseSections/
│   │   │   ├── actions/
│   │   │   │   └── sections.ts
│   │   │   ├── components/
│   │   │   │   ├── SectionForm.tsx
│   │   │   │   ├── SectionFormDialog.tsx
│   │   │   │   └── SortableSectionList.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── sections.ts
│   │   │   ├── permissions/
│   │   │   │   └── sections.ts
│   │   │   └── schemas/
│   │   │       └── sections.ts
│   │   ├── courses/
│   │   │   ├── actions/
│   │   │   │   └── courses.ts
│   │   │   ├── components/
│   │   │   │   ├── CourseForm.tsx
│   │   │   │   └── CourseTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache/
│   │   │   │   │   ├── courses.ts
│   │   │   │   │   └── userCourseAccess.ts
│   │   │   │   ├── courses.ts
│   │   │   │   └── userCourseAcccess.ts
│   │   │   ├── permissions/
│   │   │   │   └── courses.ts
│   │   │   └── schemas/
│   │   │       └── courses.ts
│   │   ├── lessons/
│   │   │   ├── actions/
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   ├── components/
│   │   │   │   ├── LessonForm.tsx
│   │   │   │   ├── LessonFormDialog.tsx
│   │   │   │   ├── SortableLessonList.tsx
│   │   │   │   └── YouTubeVideoPlayer.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache/
│   │   │   │   │   ├── lessons.ts
│   │   │   │   │   └── userLessonComplete.ts
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   ├── permissions/
│   │   │   │   ├── lessons.ts
│   │   │   │   └── userLessonComplete.ts
│   │   │   └── schemas/
│   │   │       └── lessons.ts
│   │   ├── products/
│   │   │   ├── actions/
│   │   │   │   └── products.ts
│   │   │   ├── components/
│   │   │   │   ├── ProductCard.tsx
│   │   │   │   ├── ProductForm.tsx
│   │   │   │   └── ProductTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── products.ts
│   │   │   ├── permissions/
│   │   │   │   └── products.ts
│   │   │   └── schema/
│   │   │       └── products.ts
│   │   ├── purchases/
│   │   │   ├── actions/
│   │   │   │   └── purchases.ts
│   │   │   ├── components/
│   │   │   │   ├── PurchaseTable.tsx
│   │   │   │   └── UserPurchaseTable.tsx
│   │   │   ├── db/
│   │   │   │   ├── cache.ts
│   │   │   │   └── purchases.ts
│   │   │   └── permissions/
│   │   │       └── products.ts
│   │   └── users/
│   │       └── db/
│   │           ├── cache.ts
│   │           └── users.ts
│   ├── hooks/
│   │   └── use-toast.ts
│   ├── lib/
│   │   ├── dataCache.ts
│   │   ├── formatters.ts
│   │   ├── sumArray.ts
│   │   ├── userCountryHeader.ts
│   │   └── utils.ts
│   ├── middleware.ts
│   ├── permissions/
│   │   └── general.ts
│   └── services/
│       ├── clerk.ts
│       └── stripe/
│           ├── actions/
│           │   └── stripe.ts
│           ├── components/
│           │   └── StripeCheckoutForm.tsx
│           ├── stripeClient.ts
│           └── stripeServer.ts
├── tailwind.config.ts
└── tsconfig.json
Download .txt
SYMBOL INDEX (250 symbols across 99 files)

FILE: src/app/(auth)/layout.tsx
  function AuthLayout (line 1) | async function AuthLayout({

FILE: src/app/(auth)/sign-in/[[...sign-in]]/page.tsx
  function Page (line 3) | function Page() {

FILE: src/app/(auth)/sign-up/[[...sign-up]]/page.tsx
  function Page (line 3) | function Page() {

FILE: src/app/(consumer)/courses/[courseId]/_client.tsx
  function CoursePageClient (line 15) | function CoursePageClient({

FILE: src/app/(consumer)/courses/[courseId]/layout.tsx
  function CoursePageLayout (line 21) | async function CoursePageLayout({
  function getCourse (line 48) | async function getCourse(id: string) {
  function SuspenseBoundary (line 79) | async function SuspenseBoundary({
  function getCompletedLessonIds (line 102) | async function getCompletedLessonIds(userId: string) {
  function mapCourse (line 114) | function mapCourse(

FILE: src/app/(consumer)/courses/[courseId]/lessons/[lessonId]/page.tsx
  function LessonPage (line 29) | async function LessonPage({
  function LoadingSkeleton (line 46) | function LoadingSkeleton() {
  function SuspenseBoundary (line 50) | async function SuspenseBoundary({
  function ToLessonButton (line 150) | async function ToLessonButton({
  function getPreviousLesson (line 181) | async function getPreviousLesson(lesson: {
  function getNextLesson (line 229) | async function getNextLesson(lesson: {
  function getLesson (line 274) | async function getLesson(id: string) {
  function getIsLessonComplete (line 292) | async function getIsLessonComplete({

FILE: src/app/(consumer)/courses/[courseId]/page.tsx
  function CoursePage (line 9) | async function CoursePage({
  function getCourse (line 27) | async function getCourse(id: string) {

FILE: src/app/(consumer)/courses/page.tsx
  function CoursesPage (line 38) | function CoursesPage() {
  function CourseGrid (line 57) | async function CourseGrid() {
  function SkeletonCourseCard (line 109) | function SkeletonCourseCard() {
  function getUserCourses (line 130) | async function getUserCourses(userId: string) {

FILE: src/app/(consumer)/layout.tsx
  function ConsumerLayout (line 8) | function ConsumerLayout({
  function Navbar (line 19) | function Navbar() {
  function AdminLink (line 67) | async function AdminLink() {

FILE: src/app/(consumer)/page.tsx
  function HomePage (line 9) | async function HomePage() {
  function getPublicProducts (line 23) | async function getPublicProducts() {

FILE: src/app/(consumer)/products/[productId]/page.tsx
  function ProductPage (line 38) | async function ProductPage({
  function PurchaseButton (line 160) | async function PurchaseButton({ productId }: { productId: string }) {
  function Price (line 176) | async function Price({ price }: { price: number }) {
  function getPublicProduct (line 194) | async function getPublicProduct(id: string) {

FILE: src/app/(consumer)/products/[productId]/purchase/page.tsx
  function PurchasePage (line 16) | function PurchasePage({
  function SuspendedComponent (line 30) | async function SuspendedComponent({
  function getPublicProduct (line 78) | async function getPublicProduct(id: string) {

FILE: src/app/(consumer)/products/[productId]/purchase/success/page.tsx
  function ProductPurchaseSuccessPage (line 11) | async function ProductPurchaseSuccessPage({
  function getPublicProduct (line 46) | async function getPublicProduct(id: string) {

FILE: src/app/(consumer)/products/purchase-failure/page.tsx
  function ProductPurchaseFailurePage (line 4) | async function ProductPurchaseFailurePage() {

FILE: src/app/(consumer)/purchases/[purchaseId]/page.tsx
  function PurchasePage (line 27) | async function PurchasePage({
  function SuspenseBoundary (line 43) | async function SuspenseBoundary({ purchaseId }: { purchaseId: string }) {
  function getPurchase (line 116) | async function getPurchase({ userId, id }: { userId: string; id: string ...
  function getStripeDetails (line 132) | async function getStripeDetails(
  function getReceiptUrl (line 163) | function getReceiptUrl(paymentIntent: Stripe.PaymentIntent | string | nu...
  function getPricingRows (line 174) | function getPricingRows(

FILE: src/app/(consumer)/purchases/page.tsx
  function PurchasesPage (line 16) | function PurchasesPage() {
  function SuspenseBoundary (line 27) | async function SuspenseBoundary() {
  function getPurchases (line 47) | async function getPurchases(userId: string) {

FILE: src/app/admin/courses/[courseId]/edit/page.tsx
  function EditCoursePage (line 22) | async function EditCoursePage({
  function getCourse (line 103) | async function getCourse(id: string) {

FILE: src/app/admin/courses/new/page.tsx
  function NewCoursePage (line 4) | function NewCoursePage() {

FILE: src/app/admin/courses/page.tsx
  function CoursesPage (line 19) | async function CoursesPage() {
  function getCourses (line 35) | async function getCourses() {

FILE: src/app/admin/layout.tsx
  function AdminLayout (line 6) | function AdminLayout({
  function Navbar (line 17) | function Navbar() {

FILE: src/app/admin/page.tsx
  function AdminPage (line 27) | async function AdminPage() {
  function StatCard (line 72) | function StatCard({ title, children }: { title: string; children: ReactN...
  function getPurchaseDetails (line 83) | async function getPurchaseDetails() {
  function getTotalStudents (line 120) | async function getTotalStudents() {
  function getTotalCourses (line 132) | async function getTotalCourses() {
  function getTotalProducts (line 144) | async function getTotalProducts() {
  function getTotalLessons (line 155) | async function getTotalLessons() {
  function getTotalCourseSections (line 166) | async function getTotalCourseSections() {

FILE: src/app/admin/products/[productId]/edit/page.tsx
  function EditProductPage (line 11) | async function EditProductPage({
  function getCourses (line 35) | async function getCourses() {
  function getProduct (line 45) | async function getProduct(id: string) {

FILE: src/app/admin/products/new/page.tsx
  function NewProductPage (line 9) | async function NewProductPage() {
  function getCourses (line 18) | async function getCourses() {

FILE: src/app/admin/products/page.tsx
  function ProductsPage (line 15) | async function ProductsPage() {
  function getProducts (line 31) | async function getProducts() {

FILE: src/app/admin/sales/page.tsx
  function PurchasesPage (line 10) | async function PurchasesPage() {
  function getPurchases (line 22) | async function getPurchases() {

FILE: src/app/api/clerk/syncUsers/route.ts
  function GET (line 6) | async function GET(request: Request) {

FILE: src/app/api/webhooks/clerk/route.ts
  function POST (line 8) | async function POST(req: Request) {

FILE: src/app/api/webhooks/stripe/route.ts
  function GET (line 12) | async function GET(request: NextRequest) {
  function POST (line 32) | async function POST(request: NextRequest) {
  function processStripeCheckout (line 52) | async function processStripeCheckout(checkoutSession: Stripe.Checkout.Se...
  function getProduct (line 92) | function getProduct(id: string) {
  function getUser (line 108) | function getUser(id: string) {

FILE: src/app/layout.tsx
  function RootLayout (line 11) | function RootLayout({

FILE: src/components/ActionButton.tsx
  function ActionButton (line 20) | function ActionButton({
  function LoadingTextSwap (line 72) | function LoadingTextSwap({

FILE: src/components/LoadingSpinner.tsx
  function LoadingSpinner (line 5) | function LoadingSpinner({

FILE: src/components/PageHeader.tsx
  function PageHeader (line 4) | function PageHeader({

FILE: src/components/RequiredLabelIcon.tsx
  function RequiredLabelIcon (line 5) | function RequiredLabelIcon({

FILE: src/components/Skeleton.tsx
  function SkeletonButton (line 5) | function SkeletonButton({ className }: { className?: string }) {
  function SkeletonArray (line 19) | function SkeletonArray({
  function SkeletonText (line 29) | function SkeletonText({

FILE: src/components/SortableList.tsx
  function SortableList (line 16) | function SortableList<T extends { id: string }>({
  function SortableItem (line 65) | function SortableItem({

FILE: src/components/ui/badge.tsx
  type BadgeProps (line 26) | interface BadgeProps
  function Badge (line 30) | function Badge({ className, variant, ...props }: BadgeProps) {

FILE: src/components/ui/button.tsx
  type ButtonProps (line 39) | interface ButtonProps

FILE: src/components/ui/custom/multi-select.tsx
  function MultiSelect (line 23) | function MultiSelect<Option>({

FILE: src/components/ui/form.tsx
  type FormFieldContextValue (line 20) | type FormFieldContextValue<
  type FormItemContextValue (line 67) | type FormItemContextValue = {

FILE: src/components/ui/toast.tsx
  type ToastProps (line 115) | type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
  type ToastActionElement (line 117) | type ToastActionElement = React.ReactElement<typeof ToastAction>

FILE: src/components/ui/toaster.tsx
  function Toaster (line 13) | function Toaster() {

FILE: src/data/typeOverrides/clerk.d.ts
  type CustomJwtSessionClaims (line 6) | interface CustomJwtSessionClaims {
  type UserPublicMetadata (line 11) | interface UserPublicMetadata {

FILE: src/drizzle/migrations/0000_orange_wind_dancer.sql
  type "courses" (line 5) | CREATE TABLE "courses" (
  type "course_products" (line 13) | CREATE TABLE "course_products" (
  type "course_sections" (line 21) | CREATE TABLE "course_sections" (
  type "lessons" (line 31) | CREATE TABLE "lessons" (
  type "products" (line 43) | CREATE TABLE "products" (
  type "purchases" (line 54) | CREATE TABLE "purchases" (
  type "users" (line 67) | CREATE TABLE "users" (
  type "user_course_access" (line 80) | CREATE TABLE "user_course_access" (
  type "user_lesson_complete" (line 88) | CREATE TABLE "user_lesson_complete" (

FILE: src/drizzle/schema/courseSection.ts
  type CourseSectionStatus (line 8) | type CourseSectionStatus = (typeof courseSectionStatuses)[number]

FILE: src/drizzle/schema/lesson.ts
  type LessonStatus (line 8) | type LessonStatus = (typeof lessonStatuses)[number]

FILE: src/drizzle/schema/product.ts
  type ProductStatus (line 7) | type ProductStatus = (typeof productStatuses)[number]

FILE: src/drizzle/schema/user.ts
  type UserRole (line 7) | type UserRole = (typeof userRoles)[number]

FILE: src/features/courseSections/actions/sections.ts
  function createSection (line 19) | async function createSection(
  function updateSection (line 36) | async function updateSection(
  function deleteSection (line 51) | async function deleteSection(id: string) {
  function updateSectionOrders (line 61) | async function updateSectionOrders(sectionIds: string[]) {

FILE: src/features/courseSections/components/SectionForm.tsx
  function SectionForm (line 29) | function SectionForm({

FILE: src/features/courseSections/components/SectionFormDialog.tsx
  function SectionFormDialog (line 13) | function SectionFormDialog({

FILE: src/features/courseSections/components/SortableSectionList.tsx
  function SortableSectionList (line 13) | function SortableSectionList({

FILE: src/features/courseSections/db/cache.ts
  function getCourseSectionGlobalTag (line 4) | function getCourseSectionGlobalTag() {
  function getCourseSectionIdTag (line 8) | function getCourseSectionIdTag(id: string) {
  function getCourseSectionCourseTag (line 12) | function getCourseSectionCourseTag(courseId: string) {
  function revalidateCourseSectionCache (line 16) | function revalidateCourseSectionCache({

FILE: src/features/courseSections/db/sections.ts
  function getNextCourseSectionOrder (line 6) | async function getNextCourseSectionOrder(courseId: string) {
  function insertSection (line 16) | async function insertSection(
  function updateSection (line 33) | async function updateSection(
  function deleteSection (line 52) | async function deleteSection(id: string) {
  function updateSectionOrders (line 67) | async function updateSectionOrders(sectionIds: string[]) {

FILE: src/features/courseSections/permissions/sections.ts
  function canCreateCourseSections (line 4) | function canCreateCourseSections({
  function canUpdateCourseSections (line 12) | function canUpdateCourseSections({
  function canDeleteCourseSections (line 20) | function canDeleteCourseSections({

FILE: src/features/courses/actions/courses.ts
  function createCourse (line 18) | async function createCourse(unsafeData: z.infer<typeof courseSchema>) {
  function updateCourse (line 30) | async function updateCourse(
  function deleteCourse (line 45) | async function deleteCourse(id: string) {

FILE: src/features/courses/components/CourseForm.tsx
  function CourseForm (line 22) | function CourseForm({

FILE: src/features/courses/components/CourseTable.tsx
  function CourseTable (line 16) | function CourseTable({

FILE: src/features/courses/db/cache/courses.ts
  function getCourseGlobalTag (line 4) | function getCourseGlobalTag() {
  function getCourseIdTag (line 8) | function getCourseIdTag(id: string) {
  function revalidateCourseCache (line 12) | function revalidateCourseCache(id: string) {

FILE: src/features/courses/db/cache/userCourseAccess.ts
  function getUserCourseAccessGlobalTag (line 4) | function getUserCourseAccessGlobalTag() {
  function getUserCourseAccessIdTag (line 8) | function getUserCourseAccessIdTag({
  function getUserCourseAccessUserTag (line 18) | function getUserCourseAccessUserTag(userId: string) {
  function revalidateUserCourseAccessCache (line 22) | function revalidateUserCourseAccessCache({

FILE: src/features/courses/db/courses.ts
  function insertCourse (line 6) | async function insertCourse(data: typeof CourseTable.$inferInsert) {
  function updateCourse (line 14) | async function updateCourse(
  function deleteCourse (line 29) | async function deleteCourse(id: string) {

FILE: src/features/courses/db/userCourseAcccess.ts
  function addUserCourseAccess (line 10) | async function addUserCourseAccess(
  function revokeUserCourseAccess (line 31) | async function revokeUserCourseAccess(

FILE: src/features/courses/permissions/courses.ts
  function canCreateCourses (line 3) | function canCreateCourses({ role }: { role: UserRole | undefined }) {
  function canUpdateCourses (line 7) | function canUpdateCourses({ role }: { role: UserRole | undefined }) {
  function canDeleteCourses (line 11) | function canDeleteCourses({ role }: { role: UserRole | undefined }) {

FILE: src/features/lessons/actions/lessons.ts
  function createLesson (line 19) | async function createLesson(unsafeData: z.infer<typeof lessonSchema>) {
  function updateLesson (line 33) | async function updateLesson(
  function deleteLesson (line 48) | async function deleteLesson(id: string) {
  function updateLessonOrders (line 58) | async function updateLessonOrders(lessonIds: string[]) {

FILE: src/features/lessons/actions/userLessonComplete.ts
  function updateLessonCompleteStatus (line 7) | async function updateLessonCompleteStatus(

FILE: src/features/lessons/components/LessonForm.tsx
  function LessonForm (line 31) | function LessonForm({

FILE: src/features/lessons/components/LessonFormDialog.tsx
  function LessonFormDialog (line 13) | function LessonFormDialog({

FILE: src/features/lessons/components/SortableLessonList.tsx
  function SortableLessonList (line 13) | function SortableLessonList({

FILE: src/features/lessons/components/YouTubeVideoPlayer.tsx
  function YouTubeVideoPlayer (line 5) | function YouTubeVideoPlayer({

FILE: src/features/lessons/db/cache/lessons.ts
  function getLessonGlobalTag (line 4) | function getLessonGlobalTag() {
  function getLessonIdTag (line 8) | function getLessonIdTag(id: string) {
  function getLessonCourseTag (line 12) | function getLessonCourseTag(courseId: string) {
  function revalidateLessonCache (line 16) | function revalidateLessonCache({

FILE: src/features/lessons/db/cache/userLessonComplete.ts
  function getUserLessonCompleteGlobalTag (line 4) | function getUserLessonCompleteGlobalTag() {
  function getUserLessonCompleteIdTag (line 8) | function getUserLessonCompleteIdTag({
  function getUserLessonCompleteUserTag (line 18) | function getUserLessonCompleteUserTag(userId: string) {
  function revalidateUserLessonCompleteCache (line 22) | function revalidateUserLessonCompleteCache({

FILE: src/features/lessons/db/lessons.ts
  function getNextCourseLessonOrder (line 6) | async function getNextCourseLessonOrder(sectionId: string) {
  function insertLesson (line 16) | async function insertLesson(data: typeof LessonTable.$inferInsert) {
  function updateLesson (line 37) | async function updateLesson(
  function deleteLesson (line 80) | async function deleteLesson(id: string) {
  function updateLessonOrders (line 109) | async function updateLessonOrders(lessonIds: string[]) {

FILE: src/features/lessons/db/userLessonComplete.ts
  function updateLessonCompleteStatus (line 6) | async function updateLessonCompleteStatus({

FILE: src/features/lessons/permissions/lessons.ts
  function canCreateLessons (line 16) | function canCreateLessons({ role }: { role: UserRole | undefined }) {
  function canUpdateLessons (line 20) | function canUpdateLessons({ role }: { role: UserRole | undefined }) {
  function canDeleteLessons (line 24) | function canDeleteLessons({ role }: { role: UserRole | undefined }) {
  function canViewLesson (line 28) | async function canViewLesson(

FILE: src/features/lessons/permissions/userLessonComplete.ts
  function canUpdateUserLessonCompleteStatus (line 15) | async function canUpdateUserLessonCompleteStatus(

FILE: src/features/products/actions/products.ts
  function createProduct (line 18) | async function createProduct(unsafeData: z.infer<typeof productSchema>) {
  function updateProduct (line 30) | async function updateProduct(
  function deleteProduct (line 45) | async function deleteProduct(id: string) {

FILE: src/features/products/components/ProductCard.tsx
  function ProductCard (line 16) | function ProductCard({
  function Price (line 54) | async function Price({ price }: { price: number }) {

FILE: src/features/products/components/ProductForm.tsx
  function ProductForm (line 31) | function ProductForm({

FILE: src/features/products/components/ProductTable.tsx
  function ProductTable (line 19) | function ProductTable({
  function getStatusIcon (line 100) | function getStatusIcon(status: ProductStatus) {

FILE: src/features/products/db/cache.ts
  function getProductGlobalTag (line 4) | function getProductGlobalTag() {
  function getProductIdTag (line 8) | function getProductIdTag(id: string) {
  function revalidateProductCache (line 12) | function revalidateProductCache(id: string) {

FILE: src/features/products/db/products.ts
  function userOwnsProduct (line 12) | async function userOwnsProduct({
  function insertProduct (line 33) | async function insertProduct(
  function updateProduct (line 58) | async function updateProduct(
  function deleteProduct (line 92) | async function deleteProduct(id: string) {

FILE: src/features/products/permissions/products.ts
  function canCreateProducts (line 4) | function canCreateProducts({ role }: { role: UserRole | undefined }) {
  function canUpdateProducts (line 8) | function canUpdateProducts({ role }: { role: UserRole | undefined }) {
  function canDeleteProducts (line 12) | function canDeleteProducts({ role }: { role: UserRole | undefined }) {

FILE: src/features/purchases/actions/purchases.ts
  function refundPurchase (line 10) | async function refundPurchase(id: string) {

FILE: src/features/purchases/components/PurchaseTable.tsx
  function PurchaseTable (line 20) | function PurchaseTable({
  function UserPurchaseTableSkeleton (line 101) | function UserPurchaseTableSkeleton() {

FILE: src/features/purchases/components/UserPurchaseTable.tsx
  function UserPurchaseTable (line 20) | function UserPurchaseTable({
  function UserPurchaseTableSkeleton (line 84) | function UserPurchaseTableSkeleton() {

FILE: src/features/purchases/db/cache.ts
  function getPurchaseGlobalTag (line 4) | function getPurchaseGlobalTag() {
  function getPurchaseIdTag (line 8) | function getPurchaseIdTag(id: string) {
  function getPurchaseUserTag (line 12) | function getPurchaseUserTag(userId: string) {
  function revalidatePurchaseCache (line 16) | function revalidatePurchaseCache({

FILE: src/features/purchases/db/purchases.ts
  function insertPurchase (line 6) | async function insertPurchase(
  function updatePurchase (line 30) | async function updatePurchase(

FILE: src/features/purchases/permissions/products.ts
  function canRefundPurchases (line 3) | function canRefundPurchases({ role }: { role: UserRole | undefined }) {

FILE: src/features/users/db/cache.ts
  function getUserGlobalTag (line 4) | function getUserGlobalTag() {
  function getUserIdTag (line 8) | function getUserIdTag(id: string) {
  function revalidateUserCache (line 12) | function revalidateUserCache(id: string) {

FILE: src/features/users/db/users.ts
  function insertUser (line 6) | async function insertUser(data: typeof UserTable.$inferInsert) {
  function updateUser (line 22) | async function updateUser(
  function deleteUser (line 38) | async function deleteUser({ clerkUserId }: { clerkUserId: string }) {

FILE: src/hooks/use-toast.ts
  constant TOAST_LIMIT (line 8) | const TOAST_LIMIT = 1
  constant TOAST_REMOVE_DELAY (line 9) | const TOAST_REMOVE_DELAY = 1000000
  type ToasterToast (line 11) | type ToasterToast = ToastProps & {
  function genId (line 20) | function genId() {
  type ActionType (line 25) | type ActionType = {
  type Action (line 32) | type Action =
  type State (line 50) | interface State {
  function dispatch (line 131) | function dispatch(action: Action) {
  type Toast (line 138) | type Toast = Omit<ToasterToast, "id">
  function toast (line 140) | function toast({ ...props }: Toast) {
  function useToast (line 169) | function useToast() {
  function actionToast (line 189) | function actionToast({

FILE: src/lib/dataCache.ts
  type CACHE_TAG (line 1) | type CACHE_TAG =
  function getGlobalTag (line 11) | function getGlobalTag(tag: CACHE_TAG) {
  function getIdTag (line 15) | function getIdTag(tag: CACHE_TAG, id: string) {
  function getUserTag (line 19) | function getUserTag(tag: CACHE_TAG, userId: string) {
  function getCourseTag (line 23) | function getCourseTag(tag: CACHE_TAG, courseId: string) {

FILE: src/lib/formatters.ts
  function formatPlural (line 1) | function formatPlural(
  function formatPrice (line 11) | function formatPrice(amount: number, { showZeroAsNumber = false } = {}) {
  constant DATE_FORMATTER (line 22) | const DATE_FORMATTER = new Intl.DateTimeFormat(undefined, {
  function formatDate (line 27) | function formatDate(date: Date) {
  function formatNumber (line 31) | function formatNumber(

FILE: src/lib/sumArray.ts
  function sumArray (line 1) | function sumArray<T>(array: T[], func: (item: T) => number) {

FILE: src/lib/userCountryHeader.ts
  constant COUNTRY_HEADER_KEY (line 4) | const COUNTRY_HEADER_KEY = "x-user-country"
  function setUserCountryHeader (line 6) | function setUserCountryHeader(
  function getUserCountry (line 17) | async function getUserCountry() {
  function getUserCoupon (line 22) | async function getUserCoupon() {

FILE: src/lib/utils.ts
  function cn (line 4) | function cn(...inputs: ClassValue[]) {

FILE: src/permissions/general.ts
  function canAccessAdminPages (line 3) | function canAccessAdminPages({ role }: { role: UserRole | undefined }) {

FILE: src/services/clerk.ts
  function getCurrentUser (line 11) | async function getCurrentUser({ allData = false } = {}) {
  function syncClerkUserMetadata (line 30) | function syncClerkUserMetadata(user: {
  function getUser (line 43) | async function getUser(id: string) {

FILE: src/services/stripe/actions/stripe.ts
  function getClientSessionSecret (line 7) | async function getClientSessionSecret(

FILE: src/services/stripe/components/StripeCheckoutForm.tsx
  function StripeCheckoutForm (line 10) | function StripeCheckoutForm({
Condensed preview — 144 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (286K chars).
[
  {
    "path": ".gitignore",
    "chars": 480,
    "preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
  },
  {
    "path": "LICENSE",
    "chars": 1073,
    "preview": "MIT License\n\nCopyright (c) 2025 WebDevSimplified\n\nPermission is hereby granted, free of charge, to any person obtaining "
  },
  {
    "path": "README.md",
    "chars": 1450,
    "preview": "This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-re"
  },
  {
    "path": "components.json",
    "chars": 445,
    "preview": "{\n  \"$schema\": \"https://ui.shadcn.com/schema.json\",\n  \"style\": \"new-york\",\n  \"rsc\": true,\n  \"tsx\": true,\n  \"tailwind\": {"
  },
  {
    "path": "docker-compose.yml",
    "chars": 290,
    "preview": "services:\n  db:\n    image: postgres:17.0\n    hostname: localhost\n    ports:\n      - \"5432:5432\"\n    environment:\n      -"
  },
  {
    "path": "drizzle.config.ts",
    "chars": 391,
    "preview": "import { env } from \"@/data/env/server\"\nimport { defineConfig } from \"drizzle-kit\"\n\nexport default defineConfig({\n  out:"
  },
  {
    "path": "eslint.config.mjs",
    "chars": 393,
    "preview": "import { dirname } from \"path\";\nimport { fileURLToPath } from \"url\";\nimport { FlatCompat } from \"@eslint/eslintrc\";\n\ncon"
  },
  {
    "path": "next.config.ts",
    "chars": 200,
    "preview": "import type { NextConfig } from \"next\"\n\nconst nextConfig: NextConfig = {\n  /* config options here */\n  experimental: {\n "
  },
  {
    "path": "package.json",
    "chars": 1818,
    "preview": "{\n  \"name\": \"course-platform-project\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"dev\": \"next dev\",\n  "
  },
  {
    "path": "postcss.config.mjs",
    "chars": 135,
    "preview": "/** @type {import('postcss-load-config').Config} */\nconst config = {\n  plugins: {\n    tailwindcss: {},\n  },\n};\n\nexport d"
  },
  {
    "path": "src/app/(auth)/layout.tsx",
    "chars": 215,
    "preview": "export default async function AuthLayout({\n  children,\n}: {\n  children: React.ReactNode\n}) {\n  return (\n    <div classNa"
  },
  {
    "path": "src/app/(auth)/sign-in/[[...sign-in]]/page.tsx",
    "chars": 95,
    "preview": "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",
    "chars": 95,
    "preview": "import { SignUp } from \"@clerk/nextjs\"\n\nexport default function Page() {\n  return <SignUp />\n}\n"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/_client.tsx",
    "chars": 2001,
    "preview": "\"use client\"\n\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  AccordionTrigger,\n} from \"@/components/ui/acc"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/layout.tsx",
    "chars": 3618,
    "preview": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  UserLessonCompleteTable"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/lessons/[lessonId]/page.tsx",
    "chars": 8544,
    "preview": "import { ActionButton } from \"@/components/ActionButton\"\nimport { SkeletonButton } from \"@/components/Skeleton\"\nimport {"
  },
  {
    "path": "src/app/(consumer)/courses/[courseId]/page.tsx",
    "chars": 987,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/dr"
  },
  {
    "path": "src/app/(consumer)/courses/page.tsx",
    "chars": 5069,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} from "
  },
  {
    "path": "src/app/(consumer)/layout.tsx",
    "chars": 1998,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport { canAccessAdminPages } from \"@/permissions/general\"\nimport { get"
  },
  {
    "path": "src/app/(consumer)/page.tsx",
    "chars": 1089,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { ProductTable } from \"@/drizzle/schema\"\nimport { ProductCard } from \"@/feature"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/page.tsx",
    "chars": 8292,
    "preview": "import { SkeletonButton } from \"@/components/Skeleton\"\nimport {\n  Accordion,\n  AccordionContent,\n  AccordionItem,\n  Acco"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/purchase/page.tsx",
    "chars": 2726,
    "preview": "import { LoadingSpinner } from \"@/components/LoadingSpinner\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport"
  },
  {
    "path": "src/app/(consumer)/products/[productId]/purchase/success/page.tsx",
    "chars": 1739,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport { db } from \"@/drizzle/db\"\nimport { ProductTable } from \"@/drizzl"
  },
  {
    "path": "src/app/(consumer)/products/purchase-failure/page.tsx",
    "chars": 580,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport Link from \"next/link\"\n\nexport default async function ProductPurch"
  },
  {
    "path": "src/app/(consumer)/purchases/[purchaseId]/page.tsx",
    "chars": 6360,
    "preview": "import { LoadingSpinner } from \"@/components/LoadingSpinner\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport"
  },
  {
    "path": "src/app/(consumer)/purchases/page.tsx",
    "chars": 1741,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { Button } from \"@/components/ui/button\"\nimport { db } from "
  },
  {
    "path": "src/app/admin/courses/[courseId]/edit/page.tsx",
    "chars": 4768,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { Button } from \"@/components/ui/button\"\nimport { Card, Card"
  },
  {
    "path": "src/app/admin/courses/new/page.tsx",
    "chars": 292,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { CourseForm } from \"@/features/courses/components/CourseFor"
  },
  {
    "path": "src/app/admin/courses/page.tsx",
    "chars": 1971,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport Link from \"n"
  },
  {
    "path": "src/app/admin/layout.tsx",
    "chars": 1411,
    "preview": "import { Badge } from \"@/components/ui/badge\"\nimport { UserButton } from \"@clerk/nextjs\"\nimport Link from \"next/link\"\nim"
  },
  {
    "path": "src/app/admin/page.tsx",
    "chars": 5129,
    "preview": "import {\n  Card,\n  CardDescription,\n  CardHeader,\n  CardTitle,\n} from \"@/components/ui/card\"\nimport { db } from \"@/drizz"
  },
  {
    "path": "src/app/admin/products/[productId]/edit/page.tsx",
    "chars": 1617,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable, ProductTabl"
  },
  {
    "path": "src/app/admin/products/new/page.tsx",
    "chars": 787,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/dr"
  },
  {
    "path": "src/app/admin/products/page.tsx",
    "chars": 1647,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport { PageHeader } from \"@/components/PageHeader\"\nimport Link from \"n"
  },
  {
    "path": "src/app/admin/sales/page.tsx",
    "chars": 1091,
    "preview": "import { PageHeader } from \"@/components/PageHeader\"\nimport { db } from \"@/drizzle/db\"\nimport { PurchaseTable as DbPurch"
  },
  {
    "path": "src/app/api/clerk/syncUsers/route.ts",
    "chars": 964,
    "preview": "import { insertUser } from \"@/features/users/db/users\"\nimport { syncClerkUserMetadata } from \"@/services/clerk\"\nimport {"
  },
  {
    "path": "src/app/api/webhooks/clerk/route.ts",
    "chars": 2342,
    "preview": "import { env } from \"@/data/env/server\"\nimport { deleteUser, insertUser, updateUser } from \"@/features/users/db/users\"\ni"
  },
  {
    "path": "src/app/api/webhooks/stripe/route.ts",
    "chars": 3231,
    "preview": "import { env } from \"@/data/env/server\"\nimport { db } from \"@/drizzle/db\"\nimport { ProductTable, UserTable } from \"@/dri"
  },
  {
    "path": "src/app/globals.css",
    "chars": 1025,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\nbody {\n  font-family: Arial, Helvetica, sans-serif;\n}\n\n@laye"
  },
  {
    "path": "src/app/layout.tsx",
    "chars": 561,
    "preview": "import type { Metadata } from \"next\"\nimport \"./globals.css\"\nimport { ClerkProvider } from \"@clerk/nextjs\"\nimport { Toast"
  },
  {
    "path": "src/components/ActionButton.tsx",
    "chars": 2640,
    "preview": "\"use client\"\n\nimport { ComponentPropsWithRef, ReactNode, useTransition } from \"react\"\nimport { Button } from \"./ui/butto"
  },
  {
    "path": "src/components/LoadingSpinner.tsx",
    "chars": 332,
    "preview": "import { cn } from \"@/lib/utils\"\nimport { Loader2Icon } from \"lucide-react\"\nimport { ComponentProps } from \"react\"\n\nexpo"
  },
  {
    "path": "src/components/PageHeader.tsx",
    "chars": 424,
    "preview": "import { cn } from \"@/lib/utils\"\nimport { ReactNode } from \"react\"\n\nexport function PageHeader({\n  title,\n  children,\n  "
  },
  {
    "path": "src/components/RequiredLabelIcon.tsx",
    "chars": 374,
    "preview": "import { cn } from \"@/lib/utils\"\nimport { AsteriskIcon } from \"lucide-react\"\nimport { ComponentPropsWithoutRef } from \"r"
  },
  {
    "path": "src/components/Skeleton.tsx",
    "chars": 1087,
    "preview": "import { cn } from \"@/lib/utils\"\nimport { buttonVariants } from \"./ui/button\"\nimport { ReactNode } from \"react\"\n\nexport "
  },
  {
    "path": "src/components/SortableList.tsx",
    "chars": 2714,
    "preview": "\"use client\"\n\nimport { ReactNode, useId, useOptimistic, useTransition } from \"react\"\nimport { DndContext, DragEndEvent }"
  },
  {
    "path": "src/components/ui/accordion.tsx",
    "chars": 2015,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AccordionPrimitive from \"@radix-ui/react-accordion\"\nimport { Ch"
  },
  {
    "path": "src/components/ui/alert-dialog.tsx",
    "chars": 4433,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as AlertDialogPrimitive from \"@radix-ui/react-alert-dialog\"\n\nimpor"
  },
  {
    "path": "src/components/ui/badge.tsx",
    "chars": 1140,
    "preview": "import * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\n\nimport { cn } from \"@/"
  },
  {
    "path": "src/components/ui/button.tsx",
    "chars": 2085,
    "preview": "import * as React from \"react\"\nimport { Slot } from \"@radix-ui/react-slot\"\nimport { cva, type VariantProps } from \"class"
  },
  {
    "path": "src/components/ui/card.tsx",
    "chars": 1828,
    "preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Card = React.forwardRef<\n  HTMLDivElement,\n  Rea"
  },
  {
    "path": "src/components/ui/command.tsx",
    "chars": 4887,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { type DialogProps } from \"@radix-ui/react-dialog\"\nimport { Command "
  },
  {
    "path": "src/components/ui/custom/multi-select.tsx",
    "chars": 3359,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport { Check, ChevronsUpDown } from \"lucide-react\"\n\nimport { cn } from \"@"
  },
  {
    "path": "src/components/ui/dialog.tsx",
    "chars": 3849,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as DialogPrimitive from \"@radix-ui/react-dialog\"\nimport { X } from"
  },
  {
    "path": "src/components/ui/form.tsx",
    "chars": 4111,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { Slot } fro"
  },
  {
    "path": "src/components/ui/input.tsx",
    "chars": 768,
    "preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Input = React.forwardRef<HTMLInputElement, React"
  },
  {
    "path": "src/components/ui/label.tsx",
    "chars": 724,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as LabelPrimitive from \"@radix-ui/react-label\"\nimport { cva, type "
  },
  {
    "path": "src/components/ui/popover.tsx",
    "chars": 1306,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as PopoverPrimitive from \"@radix-ui/react-popover\"\n\nimport { cn } "
  },
  {
    "path": "src/components/ui/select.tsx",
    "chars": 5632,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as SelectPrimitive from \"@radix-ui/react-select\"\nimport { Check, C"
  },
  {
    "path": "src/components/ui/table.tsx",
    "chars": 2859,
    "preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Table = React.forwardRef<\n  HTMLTableElement,\n  "
  },
  {
    "path": "src/components/ui/tabs.tsx",
    "chars": 1891,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as TabsPrimitive from \"@radix-ui/react-tabs\"\n\nimport { cn } from \""
  },
  {
    "path": "src/components/ui/textarea.tsx",
    "chars": 649,
    "preview": "import * as React from \"react\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst Textarea = React.forwardRef<\n  HTMLTextAreaEleme"
  },
  {
    "path": "src/components/ui/toast.tsx",
    "chars": 4832,
    "preview": "\"use client\"\n\nimport * as React from \"react\"\nimport * as ToastPrimitives from \"@radix-ui/react-toast\"\nimport { cva, type"
  },
  {
    "path": "src/components/ui/toaster.tsx",
    "chars": 786,
    "preview": "\"use client\"\n\nimport { useToast } from \"@/hooks/use-toast\"\nimport {\n  Toast,\n  ToastClose,\n  ToastDescription,\n  ToastPr"
  },
  {
    "path": "src/data/env/client.ts",
    "chars": 836,
    "preview": "import { createEnv } from \"@t3-oss/env-nextjs\"\nimport { z } from \"zod\"\n\nexport const env = createEnv({\n  client: {\n    N"
  },
  {
    "path": "src/data/env/server.ts",
    "chars": 748,
    "preview": "import { createEnv } from \"@t3-oss/env-nextjs\"\nimport { z } from \"zod\"\n\nexport const env = createEnv({\n  server: {\n    D"
  },
  {
    "path": "src/data/pppCoupons.ts",
    "chars": 2514,
    "preview": "import { env } from \"./env/server\"\n\nexport const pppCoupons = [\n  {\n    stripeCouponId: env.STRIPE_PPP_50_COUPON_ID,\n   "
  },
  {
    "path": "src/data/typeOverrides/clerk.d.ts",
    "chars": 230,
    "preview": "import { UserRole } from \"@/drizzle/schema\"\n\nexport {}\n\ndeclare global {\n  interface CustomJwtSessionClaims {\n    dbId?:"
  },
  {
    "path": "src/drizzle/db.ts",
    "chars": 294,
    "preview": "import { env } from \"@/data/env/server\"\nimport { drizzle } from \"drizzle-orm/node-postgres\"\nimport * as schema from \"./s"
  },
  {
    "path": "src/drizzle/migrations/0000_orange_wind_dancer.sql",
    "chars": 5912,
    "preview": "CREATE TYPE \"public\".\"course_section_status\" AS ENUM('public', 'private');--> statement-breakpoint\nCREATE TYPE \"public\"."
  },
  {
    "path": "src/drizzle/migrations/meta/0000_snapshot.json",
    "chars": 18715,
    "preview": "{\n  \"id\": \"d4730870-f365-4597-857f-fb8168440798\",\n  \"prevId\": \"00000000-0000-0000-0000-000000000000\",\n  \"version\": \"7\",\n"
  },
  {
    "path": "src/drizzle/migrations/meta/_journal.json",
    "chars": 212,
    "preview": "{\n  \"version\": \"7\",\n  \"dialect\": \"postgresql\",\n  \"entries\": [\n    {\n      \"idx\": 0,\n      \"version\": \"7\",\n      \"when\": "
  },
  {
    "path": "src/drizzle/schema/course.ts",
    "chars": 679,
    "preview": "import { relations } from \"drizzle-orm\"\nimport { pgTable, text } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, upda"
  },
  {
    "path": "src/drizzle/schema/courseProduct.ts",
    "chars": 962,
    "preview": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { CourseTable } from \"./course\"\nimport { ProductT"
  },
  {
    "path": "src/drizzle/schema/courseSection.ts",
    "chars": 1073,
    "preview": "import { integer, pgEnum, pgTable, text, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../"
  },
  {
    "path": "src/drizzle/schema/lesson.ts",
    "chars": 1119,
    "preview": "import { pgTable, text, uuid, integer, pgEnum } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../"
  },
  {
    "path": "src/drizzle/schema/product.ts",
    "chars": 821,
    "preview": "import { relations } from \"drizzle-orm\"\nimport { pgTable, text, integer, pgEnum } from \"drizzle-orm/pg-core\"\nimport { cr"
  },
  {
    "path": "src/drizzle/schema/purchase.ts",
    "chars": 1099,
    "preview": "import {\n  pgTable,\n  integer,\n  jsonb,\n  uuid,\n  text,\n  timestamp,\n} from \"drizzle-orm/pg-core\"\nimport { createdAt, id"
  },
  {
    "path": "src/drizzle/schema/user.ts",
    "chars": 812,
    "preview": "import { pgEnum, pgTable, text, timestamp } from \"drizzle-orm/pg-core\"\nimport { createdAt, id, updatedAt } from \"../sche"
  },
  {
    "path": "src/drizzle/schema/userCourseAccess.ts",
    "chars": 952,
    "preview": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, updatedAt } from \"../schemaHelpers\"\n"
  },
  {
    "path": "src/drizzle/schema/userLessonComplete.ts",
    "chars": 964,
    "preview": "import { pgTable, primaryKey, uuid } from \"drizzle-orm/pg-core\"\nimport { createdAt, updatedAt } from \"../schemaHelpers\"\n"
  },
  {
    "path": "src/drizzle/schema.ts",
    "chars": 325,
    "preview": "export * from \"./schema/course\"\nexport * from \"./schema/courseProduct\"\nexport * from \"./schema/courseSection\"\nexport * f"
  },
  {
    "path": "src/drizzle/schemaHelpers.ts",
    "chars": 316,
    "preview": "import { timestamp, uuid } from \"drizzle-orm/pg-core\"\n\nexport const id = uuid().primaryKey().defaultRandom()\nexport cons"
  },
  {
    "path": "src/features/courseSections/actions/sections.ts",
    "chars": 2046,
    "preview": "\"use server\"\n\nimport { z } from \"zod\"\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { sectionSchema } from \"."
  },
  {
    "path": "src/features/courseSections/components/SectionForm.tsx",
    "chars": 3238,
    "preview": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { s"
  },
  {
    "path": "src/features/courseSections/components/SectionFormDialog.tsx",
    "chars": 999,
    "preview": "\"use client\"\n\nimport {\n  Dialog,\n  DialogHeader,\n  DialogTitle,\n  DialogContent,\n} from \"@/components/ui/dialog\"\nimport "
  },
  {
    "path": "src/features/courseSections/components/SortableSectionList.tsx",
    "chars": 1943,
    "preview": "\"use client\"\n\nimport { SortableItem, SortableList } from \"@/components/SortableList\"\nimport { CourseSectionStatus } from"
  },
  {
    "path": "src/features/courseSections/db/cache.ts",
    "chars": 664,
    "preview": "import { getCourseTag, getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexpor"
  },
  {
    "path": "src/features/courseSections/db/sections.ts",
    "chars": 2253,
    "preview": "import { CourseSectionTable } from \"@/drizzle/schema\"\nimport { revalidateCourseSectionCache } from \"./cache\"\nimport { db"
  },
  {
    "path": "src/features/courseSections/permissions/sections.ts",
    "chars": 533,
    "preview": "import { CourseSectionTable, UserRole } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\n\nexport function canCre"
  },
  {
    "path": "src/features/courseSections/schemas/sections.ts",
    "chars": 205,
    "preview": "import { courseSectionStatuses } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const sectionSchema = z.object("
  },
  {
    "path": "src/features/courses/actions/courses.ts",
    "chars": 1470,
    "preview": "\"use server\"\n\nimport { z } from \"zod\"\nimport { courseSchema } from \"../schemas/courses\"\nimport { redirect } from \"next/n"
  },
  {
    "path": "src/features/courses/components/CourseForm.tsx",
    "chars": 2377,
    "preview": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { c"
  },
  {
    "path": "src/features/courses/components/CourseTable.tsx",
    "chars": 2324,
    "preview": "import { ActionButton } from \"@/components/ActionButton\"\nimport { Button } from \"@/components/ui/button\"\nimport {\n  Tabl"
  },
  {
    "path": "src/features/courses/db/cache/courses.ts",
    "chars": 385,
    "preview": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function get"
  },
  {
    "path": "src/features/courses/db/cache/userCourseAccess.ts",
    "chars": 792,
    "preview": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport "
  },
  {
    "path": "src/features/courses/db/courses.ts",
    "chars": 1115,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { CourseTable } from \"@/drizzle/schema\"\nimport { revalidateCourseCache } from \""
  },
  {
    "path": "src/features/courses/db/userCourseAcccess.ts",
    "chars": 1938,
    "preview": "import { db } from \"@/drizzle/db\"\nimport {\n  ProductTable,\n  PurchaseTable,\n  UserCourseAccessTable,\n} from \"@/drizzle/s"
  },
  {
    "path": "src/features/courses/permissions/courses.ts",
    "chars": 362,
    "preview": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canCreateCourses({ role }: { role: UserRole | undefined }) "
  },
  {
    "path": "src/features/courses/schemas/courses.ts",
    "chars": 152,
    "preview": "import { z } from \"zod\"\n\nexport const courseSchema = z.object({\n  name: z.string().min(1, \"Required\"),\n  description: z."
  },
  {
    "path": "src/features/lessons/actions/lessons.ts",
    "chars": 1921,
    "preview": "\"use server\"\n\nimport { z } from \"zod\"\nimport { lessonSchema } from \"../schemas/lessons\"\nimport { getCurrentUser } from \""
  },
  {
    "path": "src/features/lessons/actions/userLessonComplete.ts",
    "chars": 784,
    "preview": "\"use server\"\n\nimport { getCurrentUser } from \"@/services/clerk\"\nimport { canUpdateUserLessonCompleteStatus } from \"../pe"
  },
  {
    "path": "src/features/lessons/components/LessonForm.tsx",
    "chars": 5712,
    "preview": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { z"
  },
  {
    "path": "src/features/lessons/components/LessonFormDialog.tsx",
    "chars": 1186,
    "preview": "\"use client\"\n\nimport {\n  Dialog,\n  DialogHeader,\n  DialogTitle,\n  DialogContent,\n} from \"@/components/ui/dialog\"\nimport "
  },
  {
    "path": "src/features/lessons/components/SortableLessonList.tsx",
    "chars": 2111,
    "preview": "\"use client\"\n\nimport { SortableItem, SortableList } from \"@/components/SortableList\"\nimport { LessonStatus } from \"@/dri"
  },
  {
    "path": "src/features/lessons/components/YouTubeVideoPlayer.tsx",
    "chars": 347,
    "preview": "\"use client\"\n\nimport YouTube from \"react-youtube\"\n\nexport function YouTubeVideoPlayer({\n  videoId,\n  onFinishedVideo,\n}:"
  },
  {
    "path": "src/features/lessons/db/cache/lessons.ts",
    "chars": 594,
    "preview": "import { getCourseTag, getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexpor"
  },
  {
    "path": "src/features/lessons/db/cache/userLessonComplete.ts",
    "chars": 812,
    "preview": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport "
  },
  {
    "path": "src/features/lessons/db/lessons.ts",
    "chars": 3947,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { CourseSectionTable, LessonTable } from \"@/drizzle/schema\"\nimport { eq } from "
  },
  {
    "path": "src/features/lessons/db/userLessonComplete.ts",
    "chars": 1077,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { UserLessonCompleteTable } from \"@/drizzle/schema\"\nimport { and, eq } from \"dr"
  },
  {
    "path": "src/features/lessons/permissions/lessons.ts",
    "chars": 2003,
    "preview": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonStatus,\n  LessonTable,\n  UserCou"
  },
  {
    "path": "src/features/lessons/permissions/userLessonComplete.ts",
    "chars": 1439,
    "preview": "import { db } from \"@/drizzle/db\"\nimport {\n  CourseSectionTable,\n  CourseTable,\n  LessonTable,\n  UserCourseAccessTable,\n"
  },
  {
    "path": "src/features/lessons/schemas/lessons.ts",
    "chars": 389,
    "preview": "import { lessonStatusEnum } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const lessonSchema = z.object({\n  na"
  },
  {
    "path": "src/features/products/actions/products.ts",
    "chars": 1442,
    "preview": "\"use server\"\n\nimport { z } from \"zod\"\nimport {\n  insertProduct,\n  updateProduct as updateProductDb,\n  deleteProduct as d"
  },
  {
    "path": "src/features/products/components/ProductCard.tsx",
    "chars": 1812,
    "preview": "import { Button } from \"@/components/ui/button\"\nimport {\n  Card,\n  CardContent,\n  CardDescription,\n  CardFooter,\n  CardH"
  },
  {
    "path": "src/features/products/components/ProductForm.tsx",
    "chars": 5905,
    "preview": "\"use client\"\n\nimport { useForm } from \"react-hook-form\"\nimport { zodResolver } from \"@hookform/resolvers/zod\"\nimport { z"
  },
  {
    "path": "src/features/products/components/ProductTable.tsx",
    "chars": 3190,
    "preview": "import { ActionButton } from \"@/components/ActionButton\"\nimport { Badge } from \"@/components/ui/badge\"\nimport { Button }"
  },
  {
    "path": "src/features/products/db/cache.ts",
    "chars": 392,
    "preview": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function get"
  },
  {
    "path": "src/features/products/db/products.ts",
    "chars": 2515,
    "preview": "import { and, eq, isNull } from \"drizzle-orm\"\nimport { db } from \"@/drizzle/db\"\nimport { revalidateProductCache } from \""
  },
  {
    "path": "src/features/products/permissions/products.ts",
    "chars": 482,
    "preview": "import { ProductTable, UserRole } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\n\nexport function canCreatePro"
  },
  {
    "path": "src/features/products/schema/products.ts",
    "chars": 475,
    "preview": "import { productStatuses } from \"@/drizzle/schema\"\nimport { z } from \"zod\"\n\nexport const productSchema = z.object({\n  na"
  },
  {
    "path": "src/features/purchases/actions/purchases.ts",
    "chars": 1538,
    "preview": "\"use server\"\n\nimport { stripeServerClient } from \"@/services/stripe/stripeServer\"\nimport { canRefundPurchases } from \".."
  },
  {
    "path": "src/features/purchases/components/PurchaseTable.tsx",
    "chars": 3763,
    "preview": "import { ActionButton } from \"@/components/ActionButton\"\nimport {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} f"
  },
  {
    "path": "src/features/purchases/components/UserPurchaseTable.tsx",
    "chars": 3225,
    "preview": "import {\n  SkeletonArray,\n  SkeletonButton,\n  SkeletonText,\n} from \"@/components/Skeleton\"\nimport { Badge } from \"@/comp"
  },
  {
    "path": "src/features/purchases/db/cache.ts",
    "chars": 596,
    "preview": "import { getGlobalTag, getIdTag, getUserTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport "
  },
  {
    "path": "src/features/purchases/db/purchases.ts",
    "chars": 1379,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { PurchaseTable } from \"@/drizzle/schema\"\nimport { revalidatePurchaseCache } fr"
  },
  {
    "path": "src/features/purchases/permissions/products.ts",
    "chars": 152,
    "preview": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canRefundPurchases({ role }: { role: UserRole | undefined }"
  },
  {
    "path": "src/features/users/db/cache.ts",
    "chars": 371,
    "preview": "import { getGlobalTag, getIdTag } from \"@/lib/dataCache\"\nimport { revalidateTag } from \"next/cache\"\n\nexport function get"
  },
  {
    "path": "src/features/users/db/users.ts",
    "chars": 1423,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { UserTable } from \"@/drizzle/schema\"\nimport { eq } from \"drizzle-orm\"\nimport {"
  },
  {
    "path": "src/hooks/use-toast.ts",
    "chars": 4236,
    "preview": "\"use client\"\n\n// Inspired by react-hot-toast library\nimport * as React from \"react\"\n\nimport type { ToastActionElement, T"
  },
  {
    "path": "src/lib/dataCache.ts",
    "chars": 555,
    "preview": "type CACHE_TAG =\n  | \"products\"\n  | \"users\"\n  | \"courses\"\n  | \"userCourseAccess\"\n  | \"courseSections\"\n  | \"lessons\"\n  | "
  },
  {
    "path": "src/lib/formatters.ts",
    "chars": 961,
    "preview": "export function formatPlural(\n  count: number,\n  { singular, plural }: { singular: string; plural: string },\n  { include"
  },
  {
    "path": "src/lib/sumArray.ts",
    "chars": 129,
    "preview": "export function sumArray<T>(array: T[], func: (item: T) => number) {\n  return array.reduce((acc, item) => acc + func(ite"
  },
  {
    "path": "src/lib/userCountryHeader.ts",
    "chars": 800,
    "preview": "import { pppCoupons } from \"@/data/pppCoupons\"\nimport { headers } from \"next/headers\"\n\nconst COUNTRY_HEADER_KEY = \"x-use"
  },
  {
    "path": "src/lib/utils.ts",
    "chars": 166,
    "preview": "import { clsx, type ClassValue } from \"clsx\"\nimport { twMerge } from \"tailwind-merge\"\n\nexport function cn(...inputs: Cla"
  },
  {
    "path": "src/middleware.ts",
    "chars": 1863,
    "preview": "import { clerkMiddleware, createRouteMatcher } from \"@clerk/nextjs/server\"\nimport arcjet, { detectBot, shield, slidingWi"
  },
  {
    "path": "src/permissions/general.ts",
    "chars": 153,
    "preview": "import { UserRole } from \"@/drizzle/schema\"\n\nexport function canAccessAdminPages({ role }: { role: UserRole | undefined "
  },
  {
    "path": "src/services/clerk.ts",
    "chars": 1288,
    "preview": "import { db } from \"@/drizzle/db\"\nimport { UserRole, UserTable } from \"@/drizzle/schema\"\nimport { getUserIdTag } from \"@"
  },
  {
    "path": "src/services/stripe/actions/stripe.ts",
    "chars": 1425,
    "preview": "\"use server\"\n\nimport { getUserCoupon } from \"@/lib/userCountryHeader\"\nimport { stripeServerClient } from \"../stripeServe"
  },
  {
    "path": "src/services/stripe/components/StripeCheckoutForm.tsx",
    "chars": 702,
    "preview": "\"use client\"\n\nimport {\n  EmbeddedCheckoutProvider,\n  EmbeddedCheckout,\n} from \"@stripe/react-stripe-js\"\nimport { stripeC"
  },
  {
    "path": "src/services/stripe/stripeClient.ts",
    "chars": 178,
    "preview": "import { env } from \"@/data/env/client\"\nimport { loadStripe } from \"@stripe/stripe-js\"\n\nexport const stripeClientPromise"
  },
  {
    "path": "src/services/stripe/stripeServer.ts",
    "chars": 137,
    "preview": "import { env } from \"@/data/env/server\"\nimport Stripe from \"stripe\"\n\nexport const stripeServerClient = new Stripe(env.ST"
  },
  {
    "path": "tailwind.config.ts",
    "chars": 2339,
    "preview": "import type { Config } from \"tailwindcss\"\nimport containerQueries from \"@tailwindcss/container-queries\"\n\nexport default "
  },
  {
    "path": "tsconfig.json",
    "chars": 640,
    "preview": "{\n  \"compilerOptions\": {\n    \"noUncheckedIndexedAccess\": true,\n    \"target\": \"ES2017\",\n    \"lib\": [\"dom\", \"dom.iterable\""
  }
]

About this extraction

This page contains the full source code of the WebDevSimplified/course-platform GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 144 files (255.2 KB), approximately 68.6k tokens, and a symbol index with 250 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!