├── .env.example ├── .gitignore ├── .sanity └── runtime │ ├── app.js │ └── index.html ├── .vscode └── settings.json ├── README.md ├── actions └── createStripeCheckout.ts ├── app ├── (admin) │ ├── layout.tsx │ └── studio │ │ └── [[...tool]] │ │ └── page.tsx ├── (dashboard) │ ├── dashboard │ │ └── courses │ │ │ └── [courseId] │ │ │ ├── layout.tsx │ │ │ ├── lessons │ │ │ └── [lessonId] │ │ │ │ ├── loading.tsx │ │ │ │ └── page.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ └── loading.tsx ├── (user) │ ├── courses │ │ └── [slug] │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ ├── layout.tsx │ ├── loading.tsx │ ├── my-courses │ │ ├── loading.tsx │ │ └── page.tsx │ ├── page.tsx │ └── search │ │ └── [term] │ │ └── page.tsx ├── actions │ ├── completeLessonAction.ts │ ├── getLessonCompletionStatusAction.ts │ └── uncompleteLessonAction.ts ├── api │ ├── draft-mode │ │ ├── disable │ │ │ └── route.ts │ │ └── enable │ │ │ └── route.ts │ └── stripe-checkout │ │ └── webhook │ │ └── route.ts ├── favicon.ico ├── globals.css └── layout.tsx ├── components.json ├── components ├── CourseCard.tsx ├── CourseProgress.tsx ├── DarkModeToggle.tsx ├── DisableDraftMode.tsx ├── EnrollButton.tsx ├── Header.tsx ├── Hero.tsx ├── LessonCompleteButton.tsx ├── LoomEmbed.tsx ├── SearchInput.tsx ├── VideoPlayer.tsx ├── dashboard │ └── Sidebar.tsx ├── providers │ └── sidebar-provider.tsx ├── theme-provider.tsx └── ui │ ├── accordion.tsx │ ├── button.tsx │ ├── dropdown-menu.tsx │ ├── loader.tsx │ ├── progress.tsx │ ├── scroll-area.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ └── tooltip.tsx ├── eslint.config.mjs ├── lib ├── auth.ts ├── baseUrl.ts ├── courseProgress.ts ├── stripe.ts └── utils.ts ├── middleware.ts ├── next.config.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.mjs ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── sanity-typegen.json ├── sanity.cli.ts ├── sanity.config.ts ├── sanity.types.ts ├── sanity ├── env.ts ├── lib │ ├── adminClient.ts │ ├── client.ts │ ├── courses │ │ ├── getCourseById.ts │ │ ├── getCourseBySlug.ts │ │ ├── getCourses.ts │ │ └── searchCourses.ts │ ├── image.ts │ ├── lessons │ │ ├── completeLessonById.ts │ │ ├── getCourseProgress.ts │ │ ├── getLessonById.ts │ │ ├── getLessonCompletionStatus.ts │ │ ├── getLessonCompletions.ts │ │ └── uncompleteLessonById.ts │ ├── live.ts │ └── student │ │ ├── createEnrollment.ts │ │ ├── createStudentIfNotExists.ts │ │ ├── getEnrolledCourses.ts │ │ ├── getStudentByClerkId.ts │ │ └── isEnrolledInCourse.ts ├── schema.ts ├── schemaTypes │ ├── blockContent.ts │ ├── categoryType.ts │ ├── courseType.ts │ ├── enrollmentType.tsx │ ├── index.ts │ ├── instructorType.ts │ ├── lessonCompletionType.tsx │ ├── lessonType.ts │ ├── moduleType.ts │ └── studentType.tsx └── structure.ts ├── schema.json ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Sanity 2 | NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id 3 | NEXT_PUBLIC_SANITY_DATASET=production 4 | # Read Token 5 | SANITY_API_TOKEN=your-sanity-read-token 6 | # Full Access Admin Token 7 | SANITY_API_ADMIN_TOKEN=your-sanity-admin-token 8 | 9 | # For Sanity Studio to read 10 | # This is NEEDED for sanity to see the required variables in the studio deployment 11 | SANITY_STUDIO_PROJECT_ID=your-project-id 12 | SANITY_STUDIO_DATASET=production 13 | 14 | # Next.js 15 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 16 | 17 | # Stripe 18 | # https://dashboard.stripe.com/apikeys 19 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your-stripe-publishable-key 20 | STRIPE_SECRET_KEY=your-stripe-secret-key 21 | # Set this environment variable to support webhooks — https://stripe.com/docs/webhooks#verify-events 22 | STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret 23 | 24 | # Clerk 25 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-clerk-publishable-key 26 | CLERK_SECRET_KEY=your-clerk-secret-key -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # env files (can opt-in for committing if needed) 34 | .env 35 | .env.local 36 | .env.development.local 37 | .env.test.local 38 | .env.production.local 39 | !.env.example 40 | 41 | # vercel 42 | .vercel 43 | 44 | # typescript 45 | *.tsbuildinfo 46 | next-env.d.ts 47 | 48 | # sanity 49 | /dist 50 | -------------------------------------------------------------------------------- /.sanity/runtime/app.js: -------------------------------------------------------------------------------- 1 | 2 | // This file is auto-generated on 'sanity dev' 3 | // Modifications to this file is automatically discarded 4 | import {renderStudio} from "sanity" 5 | import studioConfig from "../../sanity.config.ts" 6 | 7 | renderStudio( 8 | document.getElementById("sanity"), 9 | studioConfig, 10 | {reactStrictMode: false, basePath: "/"} 11 | ) 12 | -------------------------------------------------------------------------------- /.sanity/runtime/index.html: -------------------------------------------------------------------------------- 1 | 2 | 6 | Sanity Studio
-------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Modern LMS Platform - Next.js 15 & Sanity CMS 2 | 3 | A modern, feature-rich Learning Management System built with Next.js 15, Sanity CMS, Clerk, and Stripe. Features real-time content updates, course progress tracking, and secure payment processing. 4 | 5 | ## Features 6 | 7 | ### For Students 8 | 9 | - 📚 Access to comprehensive course content 10 | - 📊 Real-time progress tracking 11 | - ✅ Lesson completion system 12 | - 🎯 Module-based learning paths 13 | - 🎥 Multiple video player integrations (YouTube, Vimeo, Loom) 14 | - 💳 Secure course purchases 15 | - 📱 Mobile-friendly learning experience 16 | - 🔄 Course progress synchronization 17 | 18 | ### For Course Creators 19 | 20 | - 📝 Rich content management with Sanity CMS 21 | - 📊 Student progress monitoring 22 | - 📈 Course analytics 23 | - 🎨 Customizable course structure 24 | - 📹 Multiple video hosting options 25 | - 💰 Direct payments via Stripe 26 | - 🔄 Real-time content updates 27 | - 📱 Mobile-optimized content delivery 28 | 29 | ### Technical Features 30 | 31 | - 🚀 Server Components & Server Actions 32 | - 👤 Authentication with Clerk 33 | - 💳 Payment processing with Stripe 34 | - 📝 Content management with Sanity CMS 35 | - 🎨 Modern UI with Tailwind CSS and shadcn/ui 36 | - 📱 Responsive design 37 | - 🔄 Real-time content updates 38 | - 🔒 Protected routes and content 39 | - 🌙 Dark mode support 40 | 41 | ### UI/UX Features 42 | 43 | - 🎯 Modern, clean interface 44 | - 🎨 Consistent design system using shadcn/ui 45 | - ♿ Accessible components 46 | - 🎭 Smooth transitions and animations 47 | - 📱 Responsive across all devices 48 | - 🔄 Loading states with skeleton loaders 49 | - 💫 Micro-interactions for better engagement 50 | - 🌙 Dark/Light mode toggle 51 | 52 | ## Getting Started 53 | 54 | ### Prerequisites 55 | 56 | - Node.js 18+ 57 | - npm/yarn 58 | - Stripe Account 59 | - Clerk Account 60 | - Sanity Account 61 | 62 | ### Environment Variables 63 | 64 | Create a `.env.local` file with: 65 | 66 | ```bash 67 | # Sanity 68 | NEXT_PUBLIC_SANITY_PROJECT_ID=your-project-id 69 | NEXT_PUBLIC_SANITY_DATASET=production 70 | # Read Token 71 | SANITY_API_TOKEN=your-sanity-read-token 72 | # Full Access Admin Token 73 | SANITY_API_ADMIN_TOKEN=your-sanity-admin-token 74 | 75 | # For Sanity Studio to read 76 | SANITY_STUDIO_PROJECT_ID=your-project-id 77 | SANITY_STUDIO_DATASET=production 78 | 79 | # Next.js 80 | NEXT_PUBLIC_BASE_URL=http://localhost:3000 81 | 82 | # Stripe 83 | NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=your-stripe-publishable-key 84 | STRIPE_SECRET_KEY=your-stripe-secret-key 85 | STRIPE_WEBHOOK_SECRET=your-stripe-webhook-secret 86 | 87 | # Clerk 88 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=your-clerk-publishable-key 89 | CLERK_SECRET_KEY=your-clerk-secret-key 90 | ``` 91 | 92 | ### Installation 93 | 94 | ```bash 95 | # Clone the repository 96 | git clone https://github.com/yourusername/lms-platform 97 | 98 | # Install dependencies 99 | npm install 100 | 101 | # Start the development server 102 | npm run dev 103 | 104 | # In a separate terminal, start Sanity Studio 105 | npm run sanity:dev 106 | ``` 107 | 108 | ### Setting up Sanity CMS 109 | 110 | 1. Create a Sanity account 111 | 2. Create a new project 112 | 3. Install the Sanity CLI: 113 | ```bash 114 | npm install -g @sanity/cli 115 | ``` 116 | 4. Initialize Sanity in your project: 117 | ```bash 118 | sanity init 119 | ``` 120 | 5. Deploy Sanity Studio: 121 | ```bash 122 | sanity deploy 123 | ``` 124 | 125 | ### Setting up Clerk 126 | 127 | 1. Create a Clerk application 128 | 2. Configure authentication providers 129 | 3. Set up redirect URLs 130 | 4. Add environment variables 131 | 132 | ### Setting up Stripe 133 | 134 | 1. Create a Stripe account 135 | 2. Set up webhook endpoints 136 | 3. Configure payment settings 137 | 4. Set up webhook forwarding for local development: 138 | ```bash 139 | stripe listen --forward-to localhost:3000/api/stripe-checkout/webhook 140 | ``` 141 | 142 | ## Architecture 143 | 144 | ### Content Schema 145 | 146 | - Courses 147 | 148 | - Title 149 | - Description 150 | - Price 151 | - Image 152 | - Modules 153 | - Instructor 154 | - Category 155 | 156 | - Modules 157 | 158 | - Title 159 | - Lessons 160 | - Order 161 | 162 | - Lessons 163 | 164 | - Title 165 | - Description 166 | - Video URL 167 | - Content (Rich Text) 168 | - Completion Status 169 | 170 | - Students 171 | 172 | - Profile Information 173 | - Enrolled Courses 174 | - Progress Data 175 | 176 | - Instructors 177 | - Name 178 | - Bio 179 | - Photo 180 | - Courses 181 | 182 | ### Key Components 183 | 184 | - Course Management System 185 | 186 | - Content creation and organization 187 | - Module and lesson structuring 188 | - Rich text editing 189 | - Media integration 190 | 191 | - Progress Tracking 192 | 193 | - Lesson completion 194 | - Course progress calculation 195 | - Module progress visualization 196 | 197 | - Payment Processing 198 | 199 | - Secure checkout 200 | - Course enrollment 201 | - Stripe integration 202 | 203 | - User Authentication 204 | - Clerk authentication 205 | - Protected routes 206 | - User roles 207 | 208 | ## Usage 209 | 210 | ### Creating a Course 211 | 212 | 1. Access Sanity Studio 213 | 2. Create course structure with modules and lessons 214 | 3. Add content and media 215 | 4. Publish course 216 | 217 | ### Student Experience 218 | 219 | 1. Browse available courses 220 | 2. Purchase and enroll in courses 221 | 3. Access course content 222 | 4. Track progress through modules 223 | 5. Mark lessons as complete 224 | 6. View completion certificates 225 | 226 | ## Development 227 | 228 | ### Key Files and Directories 229 | 230 | ``` 231 | /app # Next.js app directory 232 | /(dashboard) # Dashboard routes 233 | /(user) # User routes 234 | /api # API routes 235 | /components # React components 236 | /sanity # Sanity configuration 237 | /lib # Sanity utility functions 238 | /schemas # Content schemas 239 | /lib # Utility functions 240 | ``` 241 | 242 | ### Core Technologies 243 | 244 | - Next.js 15 245 | - TypeScript 246 | - Sanity CMS 247 | - Stripe Payments 248 | - Clerk Auth 249 | - Tailwind CSS 250 | - Shadcn UI 251 | - Lucide Icons 252 | 253 | ## Features in Detail 254 | 255 | ### Course Management 256 | 257 | - Flexible course structure with modules and lessons 258 | - Rich text editor for lesson content 259 | - Support for multiple video providers 260 | - Course pricing and enrollment management 261 | 262 | ### Student Dashboard 263 | 264 | - Progress tracking across all enrolled courses 265 | - Lesson completion status 266 | - Continue where you left off 267 | - Course navigation with sidebar 268 | 269 | ### Video Integration 270 | 271 | - URL Video Player 272 | - Loom Embed Support 273 | - Responsive video playback 274 | 275 | ### Payment System 276 | 277 | - Secure Stripe checkout 278 | - Course access management 279 | - Webhook integration 280 | - Payment status tracking 281 | 282 | ### Authentication 283 | 284 | - User registration and login 285 | - Protected course content 286 | - Role-based access control 287 | - Secure session management 288 | 289 | ### UI Components 290 | 291 | - Modern, responsive design 292 | - Loading states and animations 293 | - Progress indicators 294 | - Toast notifications 295 | - Modal dialogs 296 | 297 | ## Join the World's Best Developer Course & Community Zero to Full Stack Hero! 🚀 298 | 299 | ### Want to Master Modern Web Development? 300 | 301 | This project was built as part of the [Zero to Full Stack Hero](https://www.papareact.com/course) course. Join thousands of developers and learn how to build projects like this and much more! 302 | 303 | #### What You'll Learn: 304 | 305 | - 📚 Comprehensive Full Stack Development Training 306 | - 🎯 50+ Real-World Projects 307 | - 🤝 Access to the PAPAFAM Developer Community 308 | - 🎓 Weekly Live Coaching Calls 309 | - 🤖 AI & Modern Tech Stack Mastery 310 | - 💼 Career Guidance & Interview Prep 311 | 312 | #### Course Features: 313 | 314 | - ⭐ Lifetime Access to All Content 315 | - 🎯 Project-Based Learning 316 | - 💬 Private Discord Community 317 | - 🔄 Regular Content Updates 318 | - 👥 Peer Learning & Networking 319 | - 📈 Personal Growth Tracking 320 | 321 | [Join Zero to Full Stack Hero Today!](https://www.papareact.com/course) 322 | 323 | ## Support 324 | 325 | For support, join our Discord community or email support@example.com 326 | 327 | ## License 328 | 329 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 330 | 331 | --- 332 | 333 | Built with ❤️ using Next.js, Sanity, Clerk, and Stripe 334 | -------------------------------------------------------------------------------- /actions/createStripeCheckout.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import stripe from "@/lib/stripe"; 4 | import baseUrl from "@/lib/baseUrl"; 5 | 6 | import { urlFor } from "@/sanity/lib/image"; 7 | import getCourseById from "@/sanity/lib/courses/getCourseById"; 8 | import { createStudentIfNotExists } from "@/sanity/lib/student/createStudentIfNotExists"; 9 | import { clerkClient } from "@clerk/nextjs/server"; 10 | import { createEnrollment } from "@/sanity/lib/student/createEnrollment"; 11 | 12 | export async function createStripeCheckout(courseId: string, userId: string) { 13 | try { 14 | // 1. Query course details from Sanity 15 | const course = await getCourseById(courseId); 16 | const clerkUser = await (await clerkClient()).users.getUser(userId); 17 | const { emailAddresses, firstName, lastName, imageUrl } = clerkUser; 18 | const email = emailAddresses[0]?.emailAddress; 19 | 20 | if (!emailAddresses || !email) { 21 | throw new Error("User details not found"); 22 | } 23 | 24 | if (!course) { 25 | throw new Error("Course not found"); 26 | } 27 | 28 | // mid step - create a user in sanity if it doesn't exist 29 | const user = await createStudentIfNotExists({ 30 | clerkId: userId, 31 | email: email || "", 32 | firstName: firstName || email, 33 | lastName: lastName || "", 34 | imageUrl: imageUrl || "", 35 | }); 36 | 37 | if (!user) { 38 | throw new Error("User not found"); 39 | } 40 | 41 | // 2. Validate course data and prepare price for Stripe 42 | if (!course.price && course.price !== 0) { 43 | throw new Error("Course price is not set"); 44 | } 45 | const priceInCents = Math.round(course.price * 100); 46 | 47 | // if course is free, create enrollment and redirect to course page (BYPASS STRIPE CHECKOUT) 48 | if (priceInCents === 0) { 49 | await createEnrollment({ 50 | studentId: user._id, 51 | courseId: course._id, 52 | paymentId: "free", 53 | amount: 0, 54 | }); 55 | 56 | return { url: `/courses/${course.slug?.current}` }; 57 | } 58 | 59 | const { title, description, image, slug } = course; 60 | 61 | if (!title || !description || !image || !slug) { 62 | throw new Error("Course data is incomplete"); 63 | } 64 | 65 | // 3. Create and configure Stripe Checkout Session with course details 66 | const session = await stripe.checkout.sessions.create({ 67 | line_items: [ 68 | { 69 | price_data: { 70 | currency: "usd", 71 | product_data: { 72 | name: title, 73 | description: description, 74 | images: [urlFor(image).url() || ""], 75 | }, 76 | unit_amount: priceInCents, 77 | }, 78 | quantity: 1, 79 | }, 80 | ], 81 | mode: "payment", 82 | success_url: `${baseUrl}/courses/${slug.current}`, 83 | cancel_url: `${baseUrl}/courses/${slug.current}?canceled=true`, 84 | metadata: { 85 | courseId: course._id, 86 | userId: userId, 87 | }, 88 | }); 89 | 90 | // 4. Return checkout session URL for client redirect 91 | return { url: session.url }; 92 | } catch (error) { 93 | console.error("Error in createStripeCheckout:", error); 94 | throw new Error("Failed to create checkout session"); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /app/(admin)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | 3 | export const metadata: Metadata = { 4 | title: "Admin", 5 | description: "Admin", 6 | }; 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: Readonly<{ 11 | children: React.ReactNode; 12 | }>) { 13 | return
{children}
; 14 | } 15 | -------------------------------------------------------------------------------- /app/(admin)/studio/[[...tool]]/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This route is responsible for the built-in authoring environment using Sanity Studio. 3 | * All routes under your studio path is handled by this file using Next.js' catch-all routes: 4 | * https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes 5 | * 6 | * You can learn more about the next-sanity package here: 7 | * https://github.com/sanity-io/next-sanity 8 | */ 9 | 10 | import { NextStudio } from "next-sanity/studio"; 11 | import config from "../../../../sanity.config"; 12 | 13 | export const dynamic = "force-static"; 14 | 15 | export { metadata, viewport } from "next-sanity/studio"; 16 | 17 | export default function StudioPage() { 18 | return ; 19 | } 20 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/courses/[courseId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { currentUser } from "@clerk/nextjs/server"; 3 | import getCourseById from "@/sanity/lib/courses/getCourseById"; 4 | import { Sidebar } from "@/components/dashboard/Sidebar"; 5 | import { getCourseProgress } from "@/sanity/lib/lessons/getCourseProgress"; 6 | import { checkCourseAccess } from "@/lib/auth"; 7 | 8 | interface CourseLayoutProps { 9 | children: React.ReactNode; 10 | params: Promise<{ 11 | courseId: string; 12 | }>; 13 | } 14 | 15 | export default async function CourseLayout({ 16 | children, 17 | params, 18 | }: CourseLayoutProps) { 19 | const user = await currentUser(); 20 | const { courseId } = await params; 21 | 22 | if (!user?.id) { 23 | return redirect("/"); 24 | } 25 | 26 | const authResult = await checkCourseAccess(user?.id || null, courseId); 27 | if (!authResult.isAuthorized || !user?.id) { 28 | return redirect(authResult.redirect!); 29 | } 30 | 31 | const [course, progress] = await Promise.all([ 32 | getCourseById(courseId), 33 | getCourseProgress(user.id, courseId), 34 | ]); 35 | 36 | if (!course) { 37 | return redirect("/my-courses"); 38 | } 39 | 40 | return ( 41 |
42 | 43 |
{children}
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/courses/[courseId]/lessons/[lessonId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | 3 | function Loading() { 4 | return ( 5 |
6 |
7 | {/* Title skeleton */} 8 | 9 | 10 | {/* Video player skeleton */} 11 |
12 | 13 |
14 | 15 | {/* Content skeleton - Multiple lines */} 16 |
17 | 18 | 19 | 20 | 21 | 22 | {/* Paragraph gap */} 23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | 34 | export default Loading; 35 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/courses/[courseId]/lessons/[lessonId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { currentUser } from "@clerk/nextjs/server"; 3 | import { getLessonById } from "@/sanity/lib/lessons/getLessonById"; 4 | import { PortableText } from "@portabletext/react"; 5 | import { LoomEmbed } from "@/components/LoomEmbed"; 6 | import { VideoPlayer } from "@/components/VideoPlayer"; 7 | import { LessonCompleteButton } from "@/components/LessonCompleteButton"; 8 | 9 | interface LessonPageProps { 10 | params: Promise<{ 11 | courseId: string; 12 | lessonId: string; 13 | }>; 14 | } 15 | 16 | export default async function LessonPage({ params }: LessonPageProps) { 17 | const user = await currentUser(); 18 | const { courseId, lessonId } = await params; 19 | 20 | const lesson = await getLessonById(lessonId); 21 | 22 | if (!lesson) { 23 | return redirect(`/dashboard/courses/${courseId}`); 24 | } 25 | 26 | return ( 27 |
28 |
29 |
30 |

{lesson.title}

31 | 32 | {lesson.description && ( 33 |

{lesson.description}

34 | )} 35 | 36 |
37 | {/* Video Section */} 38 | {lesson.videoUrl && } 39 | 40 | {/* Loom Embed Video if loomUrl is provided */} 41 | {lesson.loomUrl && } 42 | 43 | {/* Lesson Content */} 44 | {lesson.content && ( 45 |
46 |

Lesson Notes

47 |
48 | 49 |
50 |
51 | )} 52 | 53 |
54 | 55 |
56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/courses/[courseId]/loading.tsx: -------------------------------------------------------------------------------- 1 | function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | 16 | 21 | 22 |
23 |
24 | ); 25 | } 26 | 27 | export default Loading; 28 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import getCourseById from "@/sanity/lib/courses/getCourseById"; 2 | import { redirect } from "next/navigation"; 3 | 4 | interface CoursePageProps { 5 | params: Promise<{ 6 | courseId: string; 7 | }>; 8 | } 9 | 10 | export default async function CoursePage({ params }: CoursePageProps) { 11 | const { courseId } = await params; 12 | const course = await getCourseById(courseId); 13 | 14 | if (!course) { 15 | return redirect("/"); 16 | } 17 | 18 | // Redirect to the first lesson of the first module if available 19 | if (course.modules?.[0]?.lessons?.[0]?._id) { 20 | return redirect( 21 | `/dashboard/courses/${courseId}/lessons/${course.modules[0].lessons[0]._id}` 22 | ); 23 | } 24 | 25 | return ( 26 |
27 |
28 |

Welcome to {course.title}

29 |

30 | This course has no content yet. Please check back later. 31 |

32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { ThemeProvider } from "@/components/theme-provider"; 3 | import { SanityLive } from "@/sanity/lib/live"; 4 | import { ClerkProvider } from "@clerk/nextjs"; 5 | import { SidebarProvider } from "@/components/providers/sidebar-provider"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function DashboardLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 25 | 26 |
{children}
27 |
28 |
29 | 30 | 31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/(dashboard)/loading.tsx: -------------------------------------------------------------------------------- 1 | function Loading() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 | 16 | 21 | 22 |
23 |
24 | ); 25 | } 26 | 27 | export default Loading; 28 | -------------------------------------------------------------------------------- /app/(user)/courses/[slug]/loading.tsx: -------------------------------------------------------------------------------- 1 | function Loading() { 2 | return ( 3 |
4 |
5 |
6 | ); 7 | } 8 | 9 | export default Loading; 10 | -------------------------------------------------------------------------------- /app/(user)/courses/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { urlFor } from "@/sanity/lib/image"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | import { ArrowLeft, BookOpen } from "lucide-react"; 5 | import EnrollButton from "@/components/EnrollButton"; 6 | import getCourseBySlug from "@/sanity/lib/courses/getCourseBySlug"; 7 | import { isEnrolledInCourse } from "@/sanity/lib/student/isEnrolledInCourse"; 8 | import { auth } from "@clerk/nextjs/server"; 9 | 10 | interface CoursePageProps { 11 | params: Promise<{ 12 | slug: string; 13 | }>; 14 | } 15 | 16 | export default async function CoursePage({ params }: CoursePageProps) { 17 | const { slug } = await params; 18 | const course = await getCourseBySlug(slug); 19 | const { userId } = await auth(); 20 | 21 | const isEnrolled = 22 | userId && course?._id 23 | ? await isEnrolledInCourse(userId, course._id) 24 | : false; 25 | 26 | if (!course) { 27 | return ( 28 |
29 |

Course not found

30 |
31 | ); 32 | } 33 | 34 | return ( 35 |
36 | {/* Hero Section */} 37 |
38 | {course.image && ( 39 | {course.title 46 | )} 47 |
48 |
49 | 54 | 55 | Back to Courses 56 | 57 |
58 |
59 |
60 | 61 | {course.category?.name || "Uncategorized"} 62 | 63 |
64 |

65 | {course.title} 66 |

67 |

68 | {course.description} 69 |

70 |
71 |
72 |
73 | {course.price === 0 ? "Free" : `$${course.price}`} 74 |
75 | 76 |
77 |
78 |
79 |
80 | 81 | {/* Content Section */} 82 |
83 |
84 | {/* Main Content */} 85 |
86 |
87 |

Course Content

88 |
89 | {course.modules?.map((module, index) => ( 90 |
94 |
95 |

96 | Module {index + 1}: {module.title} 97 |

98 |
99 |
100 | {module.lessons?.map((lesson, lessonIndex) => ( 101 |
105 |
106 |
107 | {lessonIndex + 1} 108 |
109 |
110 | 111 | 112 | {lesson.title} 113 | 114 |
115 |
116 |
117 | ))} 118 |
119 |
120 | ))} 121 |
122 |
123 |
124 | 125 | {/* Sidebar */} 126 |
127 |
128 |

Instructor

129 | {course.instructor && ( 130 |
131 |
132 | {course.instructor.photo && ( 133 |
134 | {course.instructor.name 140 |
141 | )} 142 |
143 |
144 | {course.instructor.name} 145 |
146 |
147 | Instructor 148 |
149 |
150 |
151 | {course.instructor.bio && ( 152 |

153 | {course.instructor.bio} 154 |

155 | )} 156 |
157 | )} 158 |
159 |
160 |
161 |
162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /app/(user)/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { ThemeProvider } from "@/components/theme-provider"; 3 | import { SanityLive } from "@/sanity/lib/live"; 4 | import Header from "@/components/Header"; 5 | import { ClerkProvider } from "@clerk/nextjs"; 6 | 7 | export const metadata: Metadata = { 8 | title: "Create Next App", 9 | description: "Generated by create next app", 10 | }; 11 | 12 | export default function UserLayout({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | return ( 18 | 19 | 25 |
26 |
27 |
{children}
28 |
29 |
30 | 31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/(user)/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "@/components/ui/loader"; 2 | import { Skeleton } from "@/components/ui/skeleton"; 3 | 4 | function Loading() { 5 | return ( 6 |
7 | {/* Hero Section Skeleton */} 8 |
9 | 10 | 11 |
12 | 13 | {/* Grid of Course Card Skeletons */} 14 |
15 | {Array.from({ length: 6 }).map((_, i) => ( 16 |
20 | {/* Image Skeleton */} 21 |
22 | 23 |
24 | 25 | {/* Content Skeleton */} 26 |
27 | 28 |
29 | 30 | 31 |
32 | 33 |
34 | {/* Instructor Skeleton */} 35 |
36 |
37 | 38 | 39 |
40 | 41 |
42 | 43 | {/* Progress Skeleton */} 44 |
45 |
46 | 47 | 48 |
49 | 50 |
51 |
52 |
53 |
54 | ))} 55 |
56 |
57 | ); 58 | } 59 | 60 | export default Loading; 61 | -------------------------------------------------------------------------------- /app/(user)/my-courses/loading.tsx: -------------------------------------------------------------------------------- 1 | function Loading() { 2 | return ( 3 |
4 |
5 | {/* Header */} 6 |
7 |
{" "} 8 | {/* Graduation cap icon */} 9 |
{" "} 10 | {/* "My Courses" text */} 11 |
12 | 13 | {/* Course Grid */} 14 |
15 | {[...Array(6)].map((_, i) => ( 16 |
20 | {/* Image */} 21 |
22 |
23 |
24 | 25 | {/* Content */} 26 |
27 | {/* Title */} 28 |
29 | 30 | {/* Description */} 31 |
32 | 33 | {/* Author */} 34 |
35 |
36 |
37 |
38 | 39 | {/* Progress bar */} 40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 | ))} 49 |
50 |
51 |
52 | ); 53 | } 54 | 55 | export default Loading; 56 | -------------------------------------------------------------------------------- /app/(user)/my-courses/page.tsx: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs/server"; 2 | import { redirect } from "next/navigation"; 3 | import { getEnrolledCourses } from "@/sanity/lib/student/getEnrolledCourses"; 4 | import Link from "next/link"; 5 | import { GraduationCap } from "lucide-react"; 6 | import { getCourseProgress } from "@/sanity/lib/lessons/getCourseProgress"; 7 | import { CourseCard } from "@/components/CourseCard"; 8 | 9 | export default async function MyCoursesPage() { 10 | const user = await currentUser(); 11 | 12 | if (!user?.id) { 13 | return redirect("/"); 14 | } 15 | 16 | const enrolledCourses = await getEnrolledCourses(user.id); 17 | 18 | // Get progress for each enrolled course 19 | const coursesWithProgress = await Promise.all( 20 | enrolledCourses.map(async ({ course }) => { 21 | if (!course) return null; 22 | const progress = await getCourseProgress(user.id, course._id); 23 | return { 24 | course, 25 | progress: progress.courseProgress, 26 | }; 27 | }) 28 | ); 29 | 30 | return ( 31 |
32 |
33 |
34 | 35 |

My Courses

36 |
37 | 38 | {enrolledCourses.length === 0 ? ( 39 |
40 |

No courses yet

41 |

42 | You haven't enrolled in any courses yet. Browse our courses 43 | to get started! 44 |

45 | 50 | Browse Courses 51 | 52 |
53 | ) : ( 54 |
55 | {coursesWithProgress.map((item) => { 56 | if (!item || !item.course) return null; 57 | 58 | return ( 59 | 65 | ); 66 | })} 67 |
68 | )} 69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /app/(user)/page.tsx: -------------------------------------------------------------------------------- 1 | import Hero from "@/components/Hero"; 2 | import { CourseCard } from "@/components/CourseCard"; 3 | import { getCourses } from "@/sanity/lib/courses/getCourses"; 4 | 5 | export const dynamic = "force-static"; 6 | export const revalidate = 3600; // revalidate at most every hour 7 | 8 | export default async function Home() { 9 | const courses = await getCourses(); 10 | 11 | return ( 12 |
13 | 14 | 15 | {/* Courses Grid */} 16 |
17 |
18 |
19 | 20 | Featured Courses 21 | 22 |
23 |
24 | 25 |
26 | {courses.map((course) => ( 27 | 32 | ))} 33 |
34 |
35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/(user)/search/[term]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from "lucide-react"; 2 | import { CourseCard } from "@/components/CourseCard"; 3 | import { searchCourses } from "@/sanity/lib/courses/searchCourses"; 4 | 5 | interface SearchPageProps { 6 | params: Promise<{ 7 | term: string; 8 | }>; 9 | } 10 | 11 | export default async function SearchPage({ params }: SearchPageProps) { 12 | const { term } = await params; 13 | const decodedTerm = decodeURIComponent(term); 14 | const courses = await searchCourses(decodedTerm); 15 | 16 | return ( 17 |
18 |
19 |
20 | 21 |
22 |

Search Results

23 |

24 | Found {courses.length} result{courses.length === 1 ? "" : "s"} for 25 | "{decodedTerm}" 26 |

27 |
28 |
29 | 30 | {courses.length === 0 ? ( 31 |
32 |

No courses found

33 |

34 | Try searching with different keywords 35 |

36 |
37 | ) : ( 38 |
39 | {courses.map((course) => ( 40 | 45 | ))} 46 |
47 | )} 48 |
49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/actions/completeLessonAction.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { completeLessonById } from "@/sanity/lib/lessons/completeLessonById"; 4 | 5 | export async function completeLessonAction(lessonId: string, clerkId: string) { 6 | try { 7 | await completeLessonById({ 8 | lessonId, 9 | clerkId, 10 | }); 11 | 12 | return { success: true }; 13 | } catch (error) { 14 | console.error("Error completing lesson:", error); 15 | return { success: false, error: "Failed to complete lesson" }; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/actions/getLessonCompletionStatusAction.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { getLessonCompletionStatus } from "@/sanity/lib/lessons/getLessonCompletionStatus"; 4 | 5 | export async function getLessonCompletionStatusAction( 6 | lessonId: string, 7 | clerkId: string 8 | ) { 9 | try { 10 | return await getLessonCompletionStatus(lessonId, clerkId); 11 | } catch (error) { 12 | console.error("Error getting lesson completion status:", error); 13 | return false; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /app/actions/uncompleteLessonAction.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import { uncompleteLessonById } from "@/sanity/lib/lessons/uncompleteLessonById"; 4 | 5 | export async function uncompleteLessonAction( 6 | lessonId: string, 7 | clerkId: string 8 | ) { 9 | try { 10 | await uncompleteLessonById({ 11 | lessonId, 12 | clerkId, 13 | }); 14 | 15 | return { success: true }; 16 | } catch (error) { 17 | console.error("Error uncompleting lesson:", error); 18 | throw error; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/api/draft-mode/disable/route.ts: -------------------------------------------------------------------------------- 1 | // src/app/api/draft-mode/disable/route.ts 2 | 3 | import { draftMode } from "next/headers"; 4 | import { NextRequest, NextResponse } from "next/server"; 5 | 6 | export async function GET(request: NextRequest) { 7 | await (await draftMode()).disable(); 8 | return NextResponse.redirect(new URL("/", request.url)); 9 | } 10 | -------------------------------------------------------------------------------- /app/api/draft-mode/enable/route.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is used to allow Presentation to set the app in Draft Mode, which will load Visual Editing 3 | * and query draft content and preview the content as it will appear once everything is published 4 | */ 5 | 6 | import { validatePreviewUrl } from "@sanity/preview-url-secret"; 7 | import { client } from "@/sanity/lib/client"; 8 | import { redirect } from "next/navigation"; 9 | import { draftMode } from "next/headers"; 10 | 11 | const token = process.env.SANITY_API_TOKEN; 12 | 13 | export async function GET(request: Request) { 14 | const { isValid, redirectTo = "/" } = await validatePreviewUrl( 15 | client.withConfig({ token }), 16 | request.url 17 | ); 18 | if (!isValid) { 19 | return new Response("Invalid secret", { status: 401 }); 20 | } 21 | 22 | (await draftMode()).enable(); 23 | 24 | redirect(redirectTo); 25 | } 26 | -------------------------------------------------------------------------------- /app/api/stripe-checkout/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import { headers } from "next/headers"; 2 | import { NextResponse } from "next/server"; 3 | import Stripe from "stripe"; 4 | import { getStudentByClerkId } from "@/sanity/lib/student/getStudentByClerkId"; 5 | import { createEnrollment } from "@/sanity/lib/student/createEnrollment"; 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 8 | apiVersion: "2023-10-16" as Stripe.LatestApiVersion, 9 | }); 10 | 11 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!; 12 | 13 | export async function POST(req: Request) { 14 | try { 15 | const body = await req.text(); 16 | const headersList = await headers(); 17 | const signature = headersList.get("stripe-signature"); 18 | 19 | if (!signature) { 20 | return new NextResponse("No signature found", { status: 400 }); 21 | } 22 | 23 | let event: Stripe.Event; 24 | 25 | try { 26 | event = stripe.webhooks.constructEvent(body, signature, webhookSecret); 27 | } catch (error: unknown) { 28 | const errorMessage = 29 | error instanceof Error ? error.message : "Unknown error"; 30 | console.error(`Webhook signature verification failed: ${errorMessage}`); 31 | 32 | return new NextResponse(`Webhook Error: ${errorMessage}`, { 33 | status: 400, 34 | }); 35 | } 36 | 37 | // Handle the checkout.session.completed event 38 | if (event.type === "checkout.session.completed") { 39 | const session = event.data.object as Stripe.Checkout.Session; 40 | 41 | // Get the courseId and userId from the metadata 42 | const courseId = session.metadata?.courseId; 43 | const userId = session.metadata?.userId; 44 | 45 | if (!courseId || !userId) { 46 | return new NextResponse("Missing metadata", { status: 400 }); 47 | } 48 | 49 | const student = await getStudentByClerkId(userId); 50 | 51 | if (!student.data) { 52 | return new NextResponse("Student not found", { status: 400 }); 53 | } 54 | 55 | // Create an enrollment record in Sanity 56 | await createEnrollment({ 57 | studentId: student.data._id, 58 | courseId, 59 | paymentId: session.id, 60 | amount: session.amount_total! / 100, // Convert from cents to dollars 61 | }); 62 | 63 | return new NextResponse(null, { status: 200 }); 64 | } 65 | 66 | return new NextResponse(null, { status: 200 }); 67 | } catch (error) { 68 | console.error("Error in webhook handler:", error); 69 | return new NextResponse("Webhook handler failed", { status: 500 }); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sonnysangha/lms-course-platform-saas-nextjs15-sanity-stripe-clerk-shadcn-typescript/ca7f8f355c4bd379a335a37e7ad1290b4e29afdc/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --accent: 0 0% 96.1%; 24 | --accent-foreground: 0 0% 9%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 0 0% 89.8%; 28 | --input: 0 0% 89.8%; 29 | --ring: 0 0% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | } 37 | .dark { 38 | --background: 0 0% 3.9%; 39 | --foreground: 0 0% 98%; 40 | --card: 0 0% 3.9%; 41 | --card-foreground: 0 0% 98%; 42 | --popover: 0 0% 3.9%; 43 | --popover-foreground: 0 0% 98%; 44 | --primary: 0 0% 98%; 45 | --primary-foreground: 0 0% 9%; 46 | --secondary: 0 0% 14.9%; 47 | --secondary-foreground: 0 0% 98%; 48 | --muted: 0 0% 14.9%; 49 | --muted-foreground: 0 0% 63.9%; 50 | --accent: 0 0% 14.9%; 51 | --accent-foreground: 0 0% 98%; 52 | --destructive: 0 62.8% 30.6%; 53 | --destructive-foreground: 0 0% 98%; 54 | --border: 0 0% 14.9%; 55 | --input: 0 0% 14.9%; 56 | --ring: 0 0% 83.1%; 57 | --chart-1: 220 70% 50%; 58 | --chart-2: 160 60% 45%; 59 | --chart-3: 30 80% 55%; 60 | --chart-4: 280 65% 60%; 61 | --chart-5: 340 75% 55%; 62 | } 63 | } 64 | 65 | @layer base { 66 | * { 67 | @apply border-border; 68 | } 69 | body { 70 | @apply bg-background text-foreground; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist, Geist_Mono } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { draftMode } from "next/headers"; 6 | import { VisualEditing } from "next-sanity"; 7 | import { DisableDraftMode } from "@/components/DisableDraftMode"; 8 | 9 | const geistSans = Geist({ 10 | variable: "--font-geist-sans", 11 | subsets: ["latin"], 12 | }); 13 | 14 | const geistMono = Geist_Mono({ 15 | variable: "--font-geist-mono", 16 | subsets: ["latin"], 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "Create Next App", 21 | description: "Generated by create next app", 22 | }; 23 | 24 | export default async function RootLayout({ 25 | children, 26 | }: Readonly<{ 27 | children: React.ReactNode; 28 | }>) { 29 | return ( 30 | 31 | 34 | {(await draftMode()).isEnabled && ( 35 | <> 36 | 37 | 38 | 39 | )} 40 | 41 | 47 | {children} 48 | 49 | 50 | 51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /components/CourseCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { BookOpen } from "lucide-react"; 6 | import { urlFor } from "@/sanity/lib/image"; 7 | import { Loader } from "@/components/ui/loader"; 8 | import { CourseProgress } from "@/components/CourseProgress"; 9 | import { 10 | GetCoursesQueryResult, 11 | GetEnrolledCoursesQueryResult, 12 | } from "@/sanity.types"; 13 | 14 | interface CourseCardProps { 15 | course: 16 | | GetCoursesQueryResult[number] 17 | | NonNullable< 18 | NonNullable["enrolledCourses"][number]["course"] 19 | >; 20 | progress?: number; 21 | href: string; 22 | } 23 | 24 | export function CourseCard({ course, progress, href }: CourseCardProps) { 25 | return ( 26 | 31 |
32 |
33 | {course.image ? ( 34 | {course.title 40 | ) : ( 41 |
42 | 43 |
44 | )} 45 |
46 |
47 | 48 | {course.category?.name || "Uncategorized"} 49 | 50 | {"price" in course && typeof course.price === "number" && ( 51 | 52 | {course.price === 0 53 | ? "Free" 54 | : `$${course.price.toLocaleString("en-US", { 55 | minimumFractionDigits: 2, 56 | })}`} 57 | 58 | )} 59 |
60 |
61 |
62 |

63 | {course.title} 64 |

65 |

66 | {course.description} 67 |

68 |
69 | {course.instructor && ( 70 |
71 |
72 | {course.instructor.photo ? ( 73 |
74 | {course.instructor.name 80 |
81 | ) : ( 82 |
83 | 84 |
85 | )} 86 | 87 | by {course.instructor.name} 88 | 89 |
90 | 91 |
92 | )} 93 | {typeof progress === "number" && ( 94 | 100 | )} 101 |
102 |
103 |
104 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /components/CourseProgress.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Progress } from "@/components/ui/progress"; 4 | import { cn } from "@/lib/utils"; 5 | 6 | interface CourseProgressProps { 7 | progress: number; 8 | variant?: "default" | "success"; 9 | size?: "default" | "sm"; 10 | showPercentage?: boolean; 11 | label?: string; 12 | className?: string; 13 | } 14 | 15 | export function CourseProgress({ 16 | progress, 17 | variant = "default", 18 | size = "default", 19 | showPercentage = true, 20 | label, 21 | className, 22 | }: CourseProgressProps) { 23 | return ( 24 |
25 |
26 | {label && {label}} 27 | {showPercentage && ( 28 | {progress}% 29 | )} 30 |
31 | div]:bg-emerald-600" 37 | )} 38 | /> 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Moon, Sun } from "lucide-react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu"; 13 | 14 | function DarkModeToggle() { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | Light 29 | 30 | setTheme("dark")}> 31 | Dark 32 | 33 | setTheme("system")}> 34 | System 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | export default DarkModeToggle; 42 | -------------------------------------------------------------------------------- /components/DisableDraftMode.tsx: -------------------------------------------------------------------------------- 1 | // src/components/DisableDraftMode.tsx 2 | 3 | "use client"; 4 | 5 | import { useDraftModeEnvironment } from "next-sanity/hooks"; 6 | import { useRouter } from "next/navigation"; 7 | 8 | export function DisableDraftMode() { 9 | const environment = useDraftModeEnvironment(); 10 | const router = useRouter(); 11 | 12 | // Only show the disable draft mode button when outside of Presentation Tool 13 | if (environment !== "live" && environment !== "unknown") { 14 | return null; 15 | } 16 | 17 | const handleClick = async () => { 18 | await fetch("/api/draft-mode/disable"); 19 | router.refresh(); 20 | }; 21 | 22 | return ( 23 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /components/EnrollButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { createStripeCheckout } from "@/actions/createStripeCheckout"; 4 | import { useUser } from "@clerk/nextjs"; 5 | import { CheckCircle } from "lucide-react"; 6 | import Link from "next/link"; 7 | import { useRouter } from "next/navigation"; 8 | import { useTransition } from "react"; 9 | 10 | function EnrollButton({ 11 | courseId, 12 | isEnrolled, 13 | }: { 14 | courseId: string; 15 | isEnrolled: boolean; 16 | }) { 17 | const { user, isLoaded: isUserLoaded } = useUser(); 18 | const router = useRouter(); 19 | const [isPending, startTransition] = useTransition(); 20 | 21 | const handleEnroll = async (courseId: string) => { 22 | startTransition(async () => { 23 | try { 24 | const userId = user?.id; 25 | if (!userId) return; 26 | 27 | const { url } = await createStripeCheckout(courseId, userId); 28 | if (url) { 29 | router.push(url); 30 | } 31 | } catch (error) { 32 | console.error("Error in handleEnroll:", error); 33 | throw new Error("Failed to create checkout session"); 34 | } 35 | }); 36 | }; 37 | 38 | // Show loading state while checking user is loading 39 | if (!isUserLoaded || isPending) { 40 | return ( 41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | // Show enrolled state with link to course 48 | if (isEnrolled) { 49 | return ( 50 | 55 | Access Course 56 | 57 | 58 | ); 59 | } 60 | 61 | // Show enroll button only when we're sure user is not enrolled 62 | return ( 63 | 89 | ); 90 | } 91 | 92 | export default EnrollButton; 93 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs"; 4 | import { BookMarkedIcon, BookOpen } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { SearchInput } from "./SearchInput"; 7 | import { Button } from "./ui/button"; 8 | import DarkModeToggle from "./DarkModeToggle"; 9 | 10 | export default function Header() { 11 | return ( 12 |
13 |
14 |
15 |
16 | 21 | 22 | 23 | Courselly 24 | 25 | 26 | 27 | 28 |
29 | 30 |
31 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 53 | 54 | 55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /components/Hero.tsx: -------------------------------------------------------------------------------- 1 | export default function Hero() { 2 | return ( 3 |
4 |
5 |
6 | 7 |
8 |
9 |

10 | Expand Your Knowledge with Our Courses 11 |

12 |

13 | Discover a world of learning with our expertly crafted courses. 14 | Learn from industry professionals and take your skills to the next 15 | level. 16 |

17 |
18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /components/LessonCompleteButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CheckCircle, Loader2, XCircle } from "lucide-react"; 4 | import { Button } from "./ui/button"; 5 | import { useState, useEffect, useTransition } from "react"; 6 | import { useRouter } from "next/navigation"; 7 | import { completeLessonAction } from "@/app/actions/completeLessonAction"; 8 | import { uncompleteLessonAction } from "@/app/actions/uncompleteLessonAction"; 9 | import { getLessonCompletionStatusAction } from "@/app/actions/getLessonCompletionStatusAction"; 10 | import { cn } from "@/lib/utils"; 11 | 12 | interface LessonCompleteButtonProps { 13 | lessonId: string; 14 | clerkId: string; 15 | } 16 | 17 | export function LessonCompleteButton({ 18 | lessonId, 19 | clerkId, 20 | }: LessonCompleteButtonProps) { 21 | const [isPending, setIsPending] = useState(false); 22 | const [isCompleted, setIsCompleted] = useState(null); 23 | const [isPendingTransition, startTransition] = useTransition(); 24 | const router = useRouter(); 25 | 26 | useEffect(() => { 27 | startTransition(async () => { 28 | try { 29 | const status = await getLessonCompletionStatusAction(lessonId, clerkId); 30 | setIsCompleted(status); 31 | } catch (error) { 32 | console.error("Error checking lesson completion status:", error); 33 | setIsCompleted(false); 34 | } 35 | }); 36 | }, [lessonId, clerkId]); 37 | 38 | const handleToggle = async () => { 39 | try { 40 | setIsPending(true); 41 | if (isCompleted) { 42 | await uncompleteLessonAction(lessonId, clerkId); 43 | } else { 44 | await completeLessonAction(lessonId, clerkId); 45 | } 46 | 47 | startTransition(async () => { 48 | const newStatus = await getLessonCompletionStatusAction( 49 | lessonId, 50 | clerkId 51 | ); 52 | setIsCompleted(newStatus); 53 | }); 54 | 55 | router.refresh(); 56 | } catch (error) { 57 | console.error("Error toggling lesson completion:", error); 58 | } finally { 59 | setIsPending(false); 60 | } 61 | }; 62 | 63 | const isLoading = isCompleted === null || isPendingTransition; 64 | 65 | return ( 66 |
67 |
68 |
69 |

70 | {isCompleted 71 | ? "Lesson completed!" 72 | : "Ready to complete this lesson?"} 73 |

74 |

75 | {isCompleted 76 | ? "You can mark it as incomplete if you need to revisit it." 77 | : "Mark it as complete when you're done."} 78 |

79 |
80 | 114 |
115 |
116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /components/LoomEmbed.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | interface LoomEmbedProps { 4 | shareUrl: string; 5 | } 6 | 7 | export function LoomEmbed({ shareUrl }: LoomEmbedProps) { 8 | // Convert share URL to embed URL 9 | const embedUrl = shareUrl.replace("/share/", "/embed/").split("?")[0]; 10 | 11 | return ( 12 |
13 |