├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── actions ├── get-analytics.ts ├── get-chapter.ts ├── get-courses.ts ├── get-dashboard-courses.ts └── get-progress.ts ├── app ├── (auth) │ └── (routes) │ │ ├── layout.tsx │ │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── (course) │ └── courses │ │ └── [courseId] │ │ ├── _components │ │ ├── CourseMobileSidebar.tsx │ │ ├── CourseNavbar.tsx │ │ ├── CourseSidebar.tsx │ │ └── CourseSidebarItem.tsx │ │ ├── chapters │ │ └── [chapterId] │ │ │ ├── _components │ │ │ ├── CourseEnrollButton.tsx │ │ │ ├── CourseProgressButton.tsx │ │ │ └── VideoPlayer.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── (main) │ ├── (dashboard) │ │ ├── dashboard │ │ │ ├── _components │ │ │ │ ├── InfoCard.tsx │ │ │ │ └── UserCard.tsx │ │ │ └── page.tsx │ │ ├── search │ │ │ ├── _components │ │ │ │ ├── Categories.tsx │ │ │ │ └── CategoryItem.tsx │ │ │ └── page.tsx │ │ └── teacher │ │ │ ├── analytics │ │ │ ├── _components │ │ │ │ ├── Chart.tsx │ │ │ │ └── DataCard.tsx │ │ │ └── page.tsx │ │ │ ├── courses │ │ │ ├── [courseId] │ │ │ │ ├── _components │ │ │ │ │ ├── Actions.tsx │ │ │ │ │ ├── CategoryForm.tsx │ │ │ │ │ ├── ChaptersForm.tsx │ │ │ │ │ ├── ChaptersList.tsx │ │ │ │ │ ├── DescriptionForm.tsx │ │ │ │ │ ├── ImageForm.tsx │ │ │ │ │ ├── PriceForm.tsx │ │ │ │ │ └── TitleForm.tsx │ │ │ │ ├── chapters │ │ │ │ │ └── [chapterId] │ │ │ │ │ │ ├── _components │ │ │ │ │ │ ├── ChapterAccessForm.tsx │ │ │ │ │ │ ├── ChapterActions.tsx │ │ │ │ │ │ ├── ChapterDescriptionForm.tsx │ │ │ │ │ │ ├── ChapterTitleForm.tsx │ │ │ │ │ │ └── ChapterVideoForm.tsx │ │ │ │ │ │ └── page.tsx │ │ │ │ └── page.tsx │ │ │ ├── _components │ │ │ │ ├── columns.tsx │ │ │ │ └── data-table.tsx │ │ │ └── page.tsx │ │ │ ├── create │ │ │ └── page.tsx │ │ │ └── layout.tsx │ ├── _components │ │ ├── Logo.tsx │ │ ├── MobileSidebar.tsx │ │ ├── Navbar.tsx │ │ ├── Sidebar.tsx │ │ ├── SidebarItem.tsx │ │ └── SidebarRoutes.tsx │ ├── layout.tsx │ ├── leaderboard │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ └── shop │ │ └── page.tsx ├── api │ ├── courses │ │ ├── [courseId] │ │ │ ├── chapters │ │ │ │ ├── [chapterId] │ │ │ │ │ ├── progress │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── publish │ │ │ │ │ │ └── route.ts │ │ │ │ │ ├── route.ts │ │ │ │ │ └── unpublish │ │ │ │ │ │ └── route.ts │ │ │ │ ├── reorder │ │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ │ ├── checkout │ │ │ │ └── route.ts │ │ │ ├── publish │ │ │ │ └── route.ts │ │ │ ├── route.ts │ │ │ └── unpublish │ │ │ │ └── route.ts │ │ └── route.ts │ ├── sign-cloudinary-params │ │ └── route.ts │ ├── user │ │ └── route.ts │ └── webhook │ │ └── route.ts ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── AvatarDialog.tsx ├── BentoGrid.tsx ├── ChangeAvatar.tsx ├── ConfirmDelete.tsx ├── Course │ ├── CourseCard.tsx │ └── CoursesList.tsx ├── Editor.tsx ├── FileUpload.tsx ├── Home │ ├── Home_CourseCard.tsx │ └── Home_CourseList.tsx ├── LogOutButton.tsx ├── NavbarRoutes.tsx ├── NavbarRoutesClient.tsx ├── Preview.tsx ├── ProfileButton.tsx ├── Providers │ ├── ConfettiProvider.tsx │ ├── ThemeProvidder.tsx │ └── ToasterPrivder.tsx ├── SearchInput.tsx ├── ThemeToggle.tsx └── ui │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── banner.tsx │ ├── button.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── combox.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── drawer.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── switch.tsx │ ├── table.tsx │ ├── textarea.tsx │ └── tooltip.tsx ├── config └── avatar-decorations.ts ├── hooks ├── use-confetti-store.ts ├── use-debounce.ts └── use-media-query.ts ├── lib ├── db.ts ├── formats.ts ├── stripe.ts ├── teacher.ts └── utils.ts ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── GRAPHIC_DESIGN_COURSE.png ├── INTRO_TO_DATA_SCIENCE.png ├── WEB_DEV_COURSE.png ├── logo.svg └── logo_dark.svg ├── scripts └── seed.ts ├── styles └── Navigation.css ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # ============== Clerk API Keys ============== # 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= # Your Clerk publishable key for the frontend 3 | CLERK_SECRET_KEY= # Your Clerk secret key for the backend 4 | 5 | # ============== Clerk URLs ============== # 6 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in # URL for sign-in page 7 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up # URL for sign-up page 8 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ # Redirect URL after successful sign-in 9 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ # Redirect URL after successful sign-up 10 | NEXT_REDIRECT=/ # Default redirect URL 11 | 12 | # ============== Database Configuration ============== # 13 | DATABASE_URL= # MongoDB Database URL 14 | 15 | # ============== Cloudinary Configuration ============== # 16 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= # Your Cloudinary cloud name 17 | NEXT_PUBLIC_CLOUDINARY_API_KEY= # Your Cloudinary API key 18 | CLOUDINARY_API_SECRET= # Your Cloudinary API secret 19 | 20 | # ============== Application Configuration ============== # 21 | NEXT_PUBLIC_TEACHER_ID= # Default Teacher ID for the application 22 | NEXT_PUBLIC_APP_URL=http://localhost:3000 # Application base URL 23 | 24 | # ============== Stripe Configuration ============== # 25 | STRIPE_API_KEY= # Your Stripe API key 26 | STRIPE_WEBHOOK_SECRET= # Your Stripe webhook secret 27 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | "**/.git": true, 4 | "**/.svn": true, 5 | "**/.hg": true, 6 | "**/CVS": true, 7 | "**/.DS_Store": true, 8 | "**/Thumbs.db": true, 9 | "**/.trunk/*actions/": true, 10 | "**/.trunk/*logs/": true, 11 | "**/.trunk/*notifications/": true, 12 | "**/.trunk/*out/": true, 13 | "**/.trunk/*plugins/": true, 14 | "node_modules": true, 15 | ".next": true, 16 | ".vscode": true, 17 | ".eslintrc.json": true, 18 | ".gitignore": true, 19 | "next-env.d.ts": true, 20 | "tsconfig.json": true, 21 | "tailwind.config.ts": true, 22 | "postcss.config.js": true, 23 | "package-lock.json": true, 24 | "components.json": true 25 | }, 26 | "hide-files.files": [ 27 | "node_modules", 28 | ".next", 29 | ".vscode", 30 | ".eslintrc.json", 31 | ".gitignore", 32 | "next-env.d.ts", 33 | "tsconfig.json", 34 | "tailwind.config.ts", 35 | "postcss.config.js", 36 | "package-lock.json", 37 | "components.json" 38 | ], 39 | "editor.tokenColorCustomizations": { 40 | "comments": "", 41 | "textMateRules": [] 42 | }, 43 | "editor.inlineSuggest.showToolbar": "always" 44 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Abdulrahman Nahhas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nahhas LMS Website 2 | 3 | Nahhas LMS is a modern, open-source Learning Management System (LMS) built with **Next.js 14**. It offers a lightweight and extendable platform for online learning, course creation, and student management. 4 | 5 | > 🕰️ _Note: This project has not received updates since **July 2024**. Community contributions are welcome to help keep it alive!_ 6 | 7 | --- 8 | 9 | ## 🎥 Demo Video 10 | 11 | Watch the LMS in action on YouTube: [Demo Video](https://youtu.be/0g7wvi6tehQ) 12 | 13 | --- 14 | 15 | ## 📚 Table of Contents 16 | 17 | - [Features](#features) 18 | - [Tech Stack](#tech-stack) 19 | - [Installation](#installation) 20 | - [Usage](#usage) 21 | - [Contributing](#contributing) 22 | - [License](#license) 23 | 24 | --- 25 | 26 | ## ✨ Features 27 | 28 | - ✅ Browse & filter available courses 29 | - 💳 Purchase courses using **Stripe** 30 | - 🧠 Track progress per chapter & course 31 | - 👨‍🎓 Student Dashboard with chapter management 32 | - 👩‍🏫 Teacher mode with course creation tools 33 | - 🧩 Add, edit & reorder chapters via drag-and-drop 34 | - 🖼️ Upload videos, thumbnails, and attachments with **UploadThing** 35 | - 🎥 Built-in video player using `react-player` 36 | - 📝 Rich text editor for chapter descriptions 37 | - 🔒 Authentication powered by **Clerk** 38 | - 📦 NoSQL storage using **MongoDB** with **Prisma ORM** 39 | 40 | --- 41 | 42 | ## 🛠 Tech Stack 43 | 44 | - **Framework:** Next.js 14 45 | - **Database:** MongoDB (via Prisma) 46 | - **Authentication:** Clerk 47 | - **UI Libraries:** Shadcn/UI, Radix UI 48 | - **Forms:** React Hook Form 49 | - **API:** Axios 50 | - **Styling:** Tailwind CSS 51 | - **Uploads:** UploadThing, Cloudinary 52 | - **Markdown:** React Markdown Preview, React MDE 53 | 54 | --- 55 | 56 | ## 🚀 Installation 57 | 58 | ### 📦 Prerequisites 59 | 60 | Ensure the following are installed on your system: 61 | 62 | - Node.js 63 | - npm or Yarn 64 | - Git 65 | 66 | ### 📁 Clone the Repository 67 | 68 | ```bash 69 | git clone https://github.com/AbdulrahmanNahhas/nahhas-lms.git 70 | cd nahhas-lms 71 | ``` 72 | 73 | ### 📥 Install Dependencies 74 | 75 | ```bash 76 | npm install 77 | # or 78 | yarn install 79 | ``` 80 | 81 | ### 🔐 Setup Environment Variables 82 | 83 | Create a `.env.local` file in the root directory: 84 | 85 | ```bash 86 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 87 | CLERK_SECRET_KEY= 88 | 89 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in 90 | NEXT_PUBLIC_CLERK_SIGN_UP_URL=/sign-up 91 | NEXT_PUBLIC_CLERK_AFTER_SIGN_IN_URL=/ 92 | NEXT_PUBLIC_CLERK_AFTER_SIGN_UP_URL=/ 93 | NEXT_REDIRECT=/ 94 | 95 | DATABASE_URL= 96 | 97 | NEXT_PUBLIC_CLOUDINARY_CLOUD_NAME= 98 | NEXT_PUBLIC_CLOUDINARY_API_KEY= 99 | CLOUDINARY_API_SECRET= 100 | 101 | NEXT_PUBLIC_TEACHER_ID= 102 | NEXT_PUBLIC_APP_URL=http://localhost:3000 103 | 104 | STRIPE_API_KEY= 105 | STRIPE_WEBHOOK_SECRET= 106 | ``` 107 | 108 | You can refer to `.env.example` for a sample config. 109 | 110 | ### ▶️ Run the Development Server 111 | 112 | ```bash 113 | npm run dev 114 | # or 115 | yarn dev 116 | ``` 117 | 118 | Visit [http://localhost:3000](http://localhost:3000) in your browser. 119 | 120 | --- 121 | 122 | ## 🧪 Usage 123 | 124 | Once the dev server is running, open your browser and go to: [http://localhost:3000](http://localhost:3000) 125 | 126 | From there, you can: 127 | - Browse courses 128 | - Log in as a student or teacher 129 | - Create, purchase, and track course content 130 | 131 | --- 132 | 133 | ## 🤝 Contributing 134 | 135 | This project was originally built as a personal learning experience and is no longer actively maintained. 136 | 137 | Feel free to fork it, explore the code, and use it as a reference or starting point for your own LMS project. I'm glad if it helps you learn something — just like it helped me. 138 | 139 | --- 140 | 141 | ## 📄 License 142 | 143 | This project is licensed under the MIT License — see the [LICENSE](LICENSE) file for more details. 144 | -------------------------------------------------------------------------------- /actions/get-analytics.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Course, Purchase } from "@prisma/client"; 3 | 4 | type PurchaseWithCourse = Purchase & { 5 | course: Course; 6 | }; 7 | 8 | const groupByCourse = (purchases: PurchaseWithCourse[]) => { 9 | const grouped: { [courseTitle: string]: number } = {}; 10 | 11 | purchases.forEach((purchase) => { 12 | const courseTitle = purchase.course.title; 13 | if (!grouped[courseTitle]) { 14 | grouped[courseTitle] = 0; 15 | } 16 | grouped[courseTitle] += purchase.course.price!; 17 | }); 18 | 19 | return grouped; 20 | }; 21 | 22 | export const getAnalytics = async (userId: string) => { 23 | try { 24 | const purchases = await db.purchase.findMany({ 25 | where: { 26 | course: { 27 | userId: userId 28 | } 29 | }, 30 | include: { 31 | course: true, 32 | } 33 | }); 34 | 35 | const groupedEarnings = groupByCourse(purchases); 36 | const data = Object.entries(groupedEarnings).map(([courseTitle, total]) => ({ 37 | name: courseTitle, 38 | total: total, 39 | })); 40 | 41 | const totalRevenue = data.reduce((acc, curr) => acc + curr.total, 0); 42 | const totalSales = purchases.length; 43 | 44 | return { 45 | data, 46 | totalRevenue, 47 | totalSales, 48 | } 49 | } catch (error) { 50 | console.log("[GET_ANALYTICS]", error); 51 | return { 52 | data: [], 53 | totalRevenue: 0, 54 | totalSales: 0, 55 | } 56 | } 57 | } -------------------------------------------------------------------------------- /actions/get-chapter.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { Chapter } from "@prisma/client"; 3 | 4 | interface GetChapterProps { 5 | userId: string; 6 | courseId: string; 7 | chapterId: string; 8 | }; 9 | 10 | export const getChapter = async ({ 11 | userId, 12 | courseId, 13 | chapterId, 14 | }: GetChapterProps) => { 15 | try { 16 | const purchase = await db.purchase.findUnique({ 17 | where: { 18 | userId_courseId: { 19 | userId, 20 | courseId, 21 | } 22 | } 23 | }); 24 | 25 | const course = await db.course.findUnique({ 26 | where: { 27 | isPublished: true, 28 | id: courseId, 29 | }, 30 | select: { 31 | price: true, 32 | } 33 | }); 34 | 35 | const chapter = await db.chapter.findUnique({ 36 | where: { 37 | id: chapterId, 38 | isPublished: true, 39 | } 40 | }); 41 | 42 | if (!chapter || !course) { 43 | throw new Error("Chapter or course not found"); 44 | } 45 | 46 | let nextChapter: Chapter | null = null; 47 | 48 | if (chapter.isFree || purchase) { 49 | nextChapter = await db.chapter.findFirst({ 50 | where: { 51 | courseId: courseId, 52 | isPublished: true, 53 | position: { 54 | gt: chapter?.position, 55 | } 56 | }, 57 | orderBy: { 58 | position: "asc", 59 | } 60 | }); 61 | } 62 | 63 | const userProgress = await db.userProgress.findUnique({ 64 | where: { 65 | userId_chapterId: { 66 | userId, 67 | chapterId, 68 | } 69 | } 70 | }); 71 | 72 | return { 73 | chapter, 74 | course, 75 | nextChapter, 76 | userProgress, 77 | purchase, 78 | }; 79 | } catch (error) { 80 | console.log("[GET_CHAPTER]", error); 81 | return { 82 | chapter: null, 83 | course: null, 84 | nextChapter: null, 85 | userProgress: null, 86 | purchase: null, 87 | } 88 | } 89 | } -------------------------------------------------------------------------------- /actions/get-courses.ts: -------------------------------------------------------------------------------- 1 | import { Category, Course } from "@prisma/client"; 2 | 3 | import getProgress from "@/actions/get-progress"; 4 | import { db } from "@/lib/db"; 5 | 6 | type CourseWithProgressWithCategory = Course & { 7 | category: Category | null; 8 | chapters: {id: string}[]; 9 | progress: number | null; 10 | } 11 | 12 | type GetCourse = { 13 | userId: string, 14 | title?: string, 15 | categoryId?: string, 16 | } 17 | 18 | export const getCourses = async ({ 19 | userId, 20 | title, 21 | categoryId 22 | }: GetCourse) => { 23 | try { 24 | const courses = await db.course.findMany({ 25 | where: { 26 | isPublished: true, 27 | title: { 28 | contains: title 29 | }, 30 | categoryId 31 | }, 32 | include: { 33 | category: true, 34 | chapters: { 35 | where: { 36 | isPublished: true 37 | }, 38 | select: { 39 | id:true 40 | } 41 | }, 42 | purchases: { 43 | where: { 44 | userId 45 | } 46 | } 47 | }, 48 | orderBy: { 49 | createdAt: "desc" 50 | } 51 | }) 52 | 53 | const coursesWithProgress: CourseWithProgressWithCategory[] = await Promise.all( 54 | courses.map(async course => { 55 | if (course.purchases.length === 0) { 56 | return { 57 | ...course, 58 | progress: null, 59 | } 60 | } 61 | 62 | const progressPrecentage = await getProgress(userId, course.id) 63 | 64 | return { 65 | ...course, 66 | progress: progressPrecentage 67 | } 68 | }) 69 | 70 | ) 71 | 72 | return coursesWithProgress 73 | } catch (error) { 74 | console.log("[GET_COURSES]", error); 75 | return []; 76 | } 77 | } -------------------------------------------------------------------------------- /actions/get-dashboard-courses.ts: -------------------------------------------------------------------------------- 1 | import { Category, Chapter, Course } from "@prisma/client"; 2 | import { db } from "@/lib/db"; 3 | 4 | import getProgress from "@/actions/get-progress"; 5 | 6 | type CourseWithProgressWithCategory = Course & { 7 | category: Category | null; 8 | chapters: Chapter[]; 9 | progress: number | null; 10 | } 11 | type DashboardCourses = { 12 | completedCourses: CourseWithProgressWithCategory[]; 13 | coursesInProgress: CourseWithProgressWithCategory[]; 14 | } 15 | 16 | export const getDashboardCourses = async (userId: string): Promise => { 17 | try { 18 | const purchasedCourses = await db.purchase.findMany({ 19 | where: { 20 | userId 21 | }, 22 | select: { 23 | course: { 24 | include: { 25 | category: true, 26 | chapters: { 27 | where: { 28 | isPublished: true 29 | } 30 | } 31 | } 32 | } 33 | } 34 | }) 35 | 36 | const courses = purchasedCourses.map((purchase) => purchase.course) as CourseWithProgressWithCategory[]; 37 | 38 | for (let course of courses) { 39 | const progress = await getProgress(userId, course.id) 40 | course["progress"] = progress; 41 | } 42 | 43 | const completedCourses = courses.filter((course) => course.progress === 100) 44 | const coursesInProgress = courses.filter((course) => (course.progress ?? 0) < 100) 45 | 46 | return { 47 | completedCourses, 48 | coursesInProgress 49 | } 50 | } catch (error) { 51 | console.log("[GET_DASHBOARD_COURSES]", error); 52 | return { 53 | completedCourses: [], 54 | coursesInProgress: [] 55 | }; 56 | } 57 | } -------------------------------------------------------------------------------- /actions/get-progress.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db'; 2 | 3 | const getProgress = async ( 4 | userId: string, 5 | courseId: string, 6 | ): Promise => { 7 | try { 8 | const publishedChapters = await db.chapter.findMany({ 9 | where: { 10 | courseId, 11 | isPublished: true 12 | }, 13 | select: { 14 | id: true 15 | } 16 | }) 17 | 18 | const publishedChaptersIds = publishedChapters.map((chapter) => chapter.id) 19 | const validCompletedChapters = await db.userProgress.count({ 20 | where: { 21 | userId: userId, 22 | chapterId: { 23 | in: publishedChaptersIds 24 | }, 25 | isCompleted: true 26 | } 27 | }) 28 | 29 | const progressPercentage = (validCompletedChapters / publishedChaptersIds.length) * 100; 30 | 31 | return progressPercentage; 32 | } catch (error) { 33 | console.log("[GET_PROGRESS]", error); 34 | return 0; 35 | } 36 | } 37 | 38 | export default getProgress -------------------------------------------------------------------------------- /app/(auth)/(routes)/layout.tsx: -------------------------------------------------------------------------------- 1 | const AuthLayout = ({ children }: { children: React.ReactNode }) => { 2 | return
{children}
; 3 | }; 4 | 5 | export default AuthLayout; -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignIn } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/(auth)/(routes)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { SignUp } from "@clerk/nextjs"; 2 | 3 | export default function Page() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseMobileSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@/components/ui/button'; 2 | import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; 3 | import { Chapter, Course, UserProgress } from '@prisma/client'; 4 | import { Menu } from 'lucide-react'; 5 | import React from 'react' 6 | import CourseSidebar from './CourseSidebar'; 7 | 8 | interface CourseMobileSidebarProps { 9 | course: Course & { 10 | chapters: (Chapter & { 11 | userProgress: UserProgress[] | null; 12 | })[]; 13 | }; 14 | progressCount: number; 15 | } 16 | 17 | const CourseMobileSidebar = ({course, progressCount}: CourseMobileSidebarProps) => { 18 | return ( 19 | 20 | 21 | 24 | 25 | 26 | 27 | 28 | 29 | ) 30 | } 31 | 32 | export default CourseMobileSidebar -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseNavbar.tsx: -------------------------------------------------------------------------------- 1 | import NavbarRoutes from '@/components/NavbarRoutes'; 2 | import { Chapter, Course, UserProgress } from '@prisma/client'; 3 | import React from 'react' 4 | import CourseMobileSidebar from './CourseMobileSidebar'; 5 | 6 | interface CourseNavbarProps { 7 | course: Course & { 8 | chapters: (Chapter & { 9 | userProgress: UserProgress[] | null; 10 | })[]; 11 | }; 12 | progressCount: number; 13 | } 14 | 15 | const CourseNavbar = ({course, progressCount}: CourseNavbarProps) => { 16 | return ( 17 |
18 | 22 | 23 |
24 | ) 25 | } 26 | 27 | export default CourseNavbar -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseSidebar.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/lib/db"; 2 | import { auth } from "@clerk/nextjs"; 3 | import { Chapter, Course, UserProgress } from "@prisma/client"; 4 | import { redirect } from "next/navigation"; 5 | import React from "react"; 6 | import CourseSidebarItem from "./CourseSidebarItem"; 7 | import { cn } from "@/lib/utils"; 8 | 9 | interface CourseSidebarProps { 10 | course: Course & { 11 | chapters: (Chapter & { 12 | userProgress: UserProgress[] | null; 13 | })[]; 14 | }; 15 | progressCount: number; 16 | } 17 | 18 | const CourseSidebar = async ({ course, progressCount }: CourseSidebarProps) => { 19 | const { userId } = auth(); 20 | if (!userId) { 21 | return redirect("/"); 22 | } 23 | 24 | const purchase = await db.purchase.findUnique({ 25 | where: { 26 | userId_courseId: { 27 | userId, 28 | courseId: course.id, 29 | }, 30 | }, 31 | }); 32 | 33 | return ( 34 |
35 |
36 |

43 | {course.title} 44 |

45 | {purchase && ( 46 | <> 47 |
48 |
52 |
53 |

59 | {Math.round(progressCount)}% Complete 60 |

61 | 62 | )} 63 |
64 |
65 | {course.chapters.map((chapter) => ( 66 | 74 | ))} 75 |
76 |
77 | ); 78 | }; 79 | 80 | export default CourseSidebar; 81 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/_components/CourseSidebarItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip"; 8 | import { cn } from "@/lib/utils"; 9 | import { usePathname, useRouter } from "next/navigation"; 10 | import { BsPauseCircle } from "react-icons/bs"; 11 | import { FaCircleCheck, FaCirclePlay, FaLock, FaPause } from "react-icons/fa6"; 12 | import { RiFolderVideoLine } from "react-icons/ri"; 13 | 14 | interface CourseSidebarItemProps { 15 | id: string; 16 | label: string; 17 | isCompleted: boolean; 18 | courseId: string; 19 | isLocked: boolean; 20 | } 21 | 22 | const CourseSidebarItem = ({ 23 | id, 24 | label, 25 | isCompleted, 26 | courseId, 27 | isLocked, 28 | }: CourseSidebarItemProps) => { 29 | const pathname = usePathname(); 30 | const router = useRouter(); 31 | 32 | const isActive = pathname?.includes(id); 33 | const Icon = isLocked 34 | ? FaLock 35 | : isCompleted 36 | ? FaCircleCheck 37 | : isActive 38 | ? BsPauseCircle 39 | : RiFolderVideoLine; 40 | 41 | const onClick = () => { 42 | router.push(`/courses/${courseId}/chapters/${id}`); 43 | }; 44 | return ( 45 | 59 | ); 60 | }; 61 | 62 | export default CourseSidebarItem; 63 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/CourseEnrollButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from '@/components/ui/button'; 4 | import { formatPrice } from '@/lib/formats'; 5 | import axios from 'axios'; 6 | import { useState } from 'react' 7 | import { toast } from 'sonner'; 8 | 9 | interface CourseEnrollButtonProps { 10 | courseId: string; 11 | price: number; 12 | } 13 | 14 | const CourseEnrollButton = ({courseId, price}: CourseEnrollButtonProps) => { 15 | const [isLoading, setIsloading] = useState(false) 16 | 17 | const onClick = async () => { 18 | try { 19 | setIsloading(true); 20 | 21 | const response = await axios.post(`/api/courses/${courseId}/checkout`); 22 | 23 | window.location.assign(response.data.url) 24 | } catch (error) { 25 | toast.error(("something went wrong")); 26 | } finally { 27 | setIsloading(false); 28 | } 29 | } 30 | 31 | return ( 32 | 35 | ) 36 | } 37 | 38 | export default CourseEnrollButton -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/CourseProgressButton.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 5 | import axios from "axios"; 6 | import { useRouter } from "next/navigation"; 7 | import React, { useState } from "react"; 8 | import { FaCheck, FaX } from "react-icons/fa6"; 9 | import { toast } from "sonner"; 10 | 11 | interface CourseProgressButtonProps { 12 | chapterId: string; 13 | courseId: string; 14 | nextChapterId?: string; 15 | isCompleted?: boolean; 16 | } 17 | 18 | const CourseProgressButton = ({ 19 | chapterId, 20 | courseId, 21 | nextChapterId, 22 | isCompleted, 23 | }: CourseProgressButtonProps) => { 24 | const router = useRouter(); 25 | const confetti = useConfettiStore(); 26 | const [isLoading, setIsLoading] = useState(false) 27 | 28 | const onClick = async () => { 29 | try { 30 | setIsLoading(true) 31 | 32 | const promise = async () => { 33 | await axios.put(`/api/courses/${courseId}/chapters/${chapterId}/progress`, { 34 | isCompleted: !isCompleted, 35 | }) 36 | 37 | if(!isCompleted && !nextChapterId) { 38 | confetti.onOpen() 39 | } 40 | 41 | if(!isCompleted && nextChapterId) { 42 | router.push(`/courses/${courseId}/chapters/${nextChapterId}`) 43 | } 44 | 45 | return 'Chapter updated successfully'; 46 | }; 47 | 48 | toast.promise(promise, { 49 | loading: 'Loading...', 50 | success: (data) => { 51 | setIsLoading(false); 52 | router.refresh(); 53 | return data; 54 | }, 55 | error: 'Something went wrong', 56 | }); 57 | } catch (error) { 58 | toast.error("Something went wrong") 59 | } finally { 60 | setIsLoading(false) 61 | } 62 | } 63 | 64 | return ( 65 | <> 66 | {isCompleted ? ( 67 | 70 | ) : ( 71 | 74 | )} 75 | 76 | ); 77 | }; 78 | 79 | export default CourseProgressButton; 80 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/_components/VideoPlayer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 4 | import { cn } from "@/lib/utils"; 5 | import axios from "axios"; 6 | import { Loader2 } from "lucide-react"; 7 | import dynamic from "next/dynamic"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | import { useMemo, useState } from "react"; 11 | import { FaLock } from "react-icons/fa6"; 12 | import { toast } from "sonner"; 13 | 14 | interface VideoPlayerProps { 15 | url: string; 16 | isLocked: boolean; 17 | nextChapterId?: string; 18 | chapterId: string; 19 | courseId: string; 20 | completeOnEnd: boolean; 21 | } 22 | 23 | const VideoPlayer = ({ 24 | url, 25 | isLocked, 26 | nextChapterId, 27 | chapterId, 28 | courseId, 29 | completeOnEnd, 30 | }: VideoPlayerProps) => { 31 | const ReactPlayer = useMemo( 32 | () => dynamic(() => import("react-player/lazy"), { ssr: false }), 33 | [] 34 | ); 35 | const router = useRouter(); 36 | const confetti = useConfettiStore(); 37 | 38 | const [isReady, setIsReady] = useState(false); 39 | 40 | const onEnd = async () => { 41 | try { 42 | if (completeOnEnd) { 43 | const promise = async () => { 44 | await axios.put( 45 | `/api/courses/${courseId}/chapters/${chapterId}/progress`, 46 | { 47 | isCompleted: true, 48 | } 49 | ); 50 | 51 | return "Progress Updated!"; 52 | }; 53 | 54 | toast.promise(promise, { 55 | loading: "Loading...", 56 | success: (data) => { 57 | if (nextChapterId) { 58 | router.push(`/courses/${courseId}/chapters/${nextChapterId}`); 59 | } 60 | router.refresh(); 61 | if (!nextChapterId) { 62 | confetti.onOpen(); 63 | } 64 | return data; 65 | }, 66 | error: "Something went wrong", 67 | }); 68 | } 69 | } catch (error) { 70 | toast.error("Something went wrong"); 71 | } 72 | }; 73 | 74 | return ( 75 |
76 | {!isReady && !isLocked && ( 77 |
78 | 79 | Loading 80 |
81 | )} 82 | {isLocked && ( 83 |
84 | 85 |

This Chapter is locked

86 |
87 | )} 88 | {!isLocked && ( 89 | setIsReady(true)} 96 | onEnded={onEnd} 97 | controls 98 | autoplay 99 | /> 100 | )} 101 |
102 | ); 103 | }; 104 | 105 | export default VideoPlayer; 106 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/chapters/[chapterId]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import VideoPlayer from "./_components/VideoPlayer"; 3 | import { redirect } from "next/navigation"; 4 | import { auth } from "@clerk/nextjs"; 5 | import { getChapter } from "@/actions/get-chapter"; 6 | import { Banner } from "@/components/ui/banner"; 7 | import Preview from "@/components/Preview"; 8 | import { Button } from "@/components/ui/button"; 9 | import { FaCheck } from "react-icons/fa6"; 10 | import CourseEnrollButton from "./_components/CourseEnrollButton"; 11 | import CourseProgressButton from "./_components/CourseProgressButton"; 12 | 13 | interface ChapterProps { 14 | params: { 15 | chapterId: string; 16 | courseId: string; 17 | }; 18 | } 19 | 20 | const page = async ({ params }: ChapterProps) => { 21 | const { userId } = auth(); 22 | if (!userId) { 23 | return redirect("/"); 24 | } 25 | 26 | const { chapter, course, nextChapter, userProgress, purchase } = 27 | await getChapter({ 28 | userId, 29 | chapterId: params.chapterId, 30 | courseId: params.courseId, 31 | }); 32 | if (!chapter || !course) { 33 | return redirect("/"); 34 | } 35 | 36 | const isLocked = !chapter.isFree && !purchase; 37 | const completeOnEnd = !!purchase && !userProgress?.isCompleted; 38 | 39 | return ( 40 |
41 | {userProgress?.isCompleted && ( 42 | 43 | )} 44 | {isLocked && ( 45 | 49 | )} 50 | 51 |
52 |
53 | 54 | 62 |
63 |
64 |
65 |

{chapter.title}

66 | {purchase ? ( 67 | 73 | ) : ( 74 | 78 | )} 79 |
80 | 81 |
82 |
83 |
84 | ); 85 | }; 86 | 87 | export default page; 88 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/layout.tsx: -------------------------------------------------------------------------------- 1 | import getProgress from "@/actions/get-progress"; 2 | import { db } from "@/lib/db"; 3 | import { auth } from "@clerk/nextjs"; 4 | import { redirect } from "next/navigation"; 5 | import React from "react"; 6 | import CourseSidebar from "./_components/CourseSidebar"; 7 | import CourseNavbar from "./_components/CourseNavbar"; 8 | 9 | const layout = async ({ 10 | children, 11 | params, 12 | }: { 13 | children: React.ReactNode; 14 | params: { courseId: string }; 15 | }) => { 16 | const { userId } = auth(); 17 | if (!userId) { 18 | return redirect("/"); 19 | } 20 | 21 | const course = await db.course.findUnique({ 22 | where: { 23 | id: params.courseId, 24 | }, 25 | include: { 26 | chapters: { 27 | where: { 28 | isPublished: true, 29 | }, 30 | include: { 31 | userProgress: { 32 | where: { 33 | userId, 34 | }, 35 | }, 36 | }, 37 | orderBy: { 38 | position: "asc", 39 | }, 40 | }, 41 | }, 42 | }); 43 | 44 | if (!course) { 45 | return redirect("/"); 46 | } 47 | 48 | const progressCount = await getProgress(userId, course.id); 49 | 50 | return ( 51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 |
{children}
59 |
60 | ); 61 | }; 62 | 63 | export default layout; 64 | -------------------------------------------------------------------------------- /app/(course)/courses/[courseId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from '@/lib/db' 2 | import { redirect } from 'next/navigation' 3 | 4 | const page = async ({params}: {params: {courseId: string}}) => { 5 | if (!params.courseId) { 6 | return redirect("/") 7 | } 8 | 9 | const course = await db.course.findUnique({ 10 | where: { 11 | id: params.courseId 12 | }, 13 | include: { 14 | chapters: { 15 | where: { 16 | isPublished: true 17 | }, 18 | orderBy: { 19 | position: "asc" 20 | } 21 | } 22 | } 23 | }) 24 | 25 | if (!course) { 26 | return redirect("/") 27 | } 28 | 29 | return redirect(`/courses/${course.id}/chapters/${course.chapters[0].id}`) 30 | } 31 | 32 | export default page -------------------------------------------------------------------------------- /app/(main)/(dashboard)/dashboard/_components/InfoCard.tsx: -------------------------------------------------------------------------------- 1 | import { LucideIcon } from "lucide-react"; 2 | 3 | interface InfoCardProps { 4 | numberOfItems: any; 5 | nameOfItems: string; 6 | label: string; 7 | icon: LucideIcon; 8 | } 9 | 10 | export const InfoCard = ({ 11 | nameOfItems, 12 | icon: Icon, 13 | numberOfItems, 14 | label, 15 | }: InfoCardProps) => { 16 | return ( 17 |
18 | 19 |
20 |

{label}

21 |

22 | {numberOfItems} {nameOfItems} 23 |

24 |
25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/dashboard/_components/UserCard.tsx: -------------------------------------------------------------------------------- 1 | import { auth, clerkClient } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | import { Button } from "@/components/ui/button"; 4 | import { ArrowRight, Edit } from "lucide-react"; 5 | import { redirect } from "next/navigation"; 6 | import { ChangeAvatarDialog } from "@/components/ChangeAvatar"; 7 | import { avatarDecorations } from "@/config/avatar-decorations"; 8 | 9 | const UserCard = async () => { 10 | const { userId } = auth(); 11 | if (!userId) { 12 | return redirect("/"); 13 | } 14 | const user = await clerkClient.users.getUser(userId); 15 | 16 | // XP 17 | const xp = user.publicMetadata.xp as number || 0; 18 | function getHundreds(number: number) { 19 | return Math.floor(number / 100) * 100; 20 | } 21 | function removeHundreds(number: number): number { 22 | return number % 100; 23 | } 24 | function calculateProgress(currentValue: number, totalValue: number) { 25 | return Math.round((currentValue / totalValue) * 100); 26 | } 27 | 28 | // Image 29 | const image = user.publicMetadata.image as string || ""; 30 | const [groupName, indexString] = image.split("-"); 31 | const index = Number(indexString); 32 | const matchingDecoration = avatarDecorations.find( 33 | (decoration) => decoration.title === groupName 34 | ); 35 | let imageURL = "" 36 | if (matchingDecoration) { 37 | imageURL = matchingDecoration.images[index]; 38 | } 39 | 40 | 41 | return ( 42 |
43 |
44 | {user.hasImage ? ( 45 |
46 | {/* */} 47 | logo 54 | {/* {imageURL !== "" && image !== "" && ( 55 | border 56 | )} */} 57 |
58 | ) : ( 59 | logo 66 | )} 67 |
68 |

69 | {user.firstName?.charAt(0)?.toUpperCase()} 70 | {user.firstName?.slice(1)} {user.lastName?.charAt(0)?.toUpperCase()} 71 | {user.lastName?.slice(1)} 72 |

73 |
74 |
75 |
81 |
82 |
83 | 84 | 85 | {getHundreds(xp)+100} XP 86 | 87 |
88 |
89 |
90 |
91 |
92 | 99 | 107 |
108 |
109 | ); 110 | }; 111 | 112 | export default UserCard; 113 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { getDashboardCourses } from "@/actions/get-dashboard-courses"; 2 | import CoursesList from "@/components/Course/CoursesList"; 3 | import { auth, clerkClient } from "@clerk/nextjs"; 4 | import { CheckCircle, Clock, Star } from "lucide-react"; 5 | import { redirect } from "next/navigation"; 6 | import React from "react"; 7 | import { InfoCard } from "./_components/InfoCard"; 8 | import UserCard from "./_components/UserCard"; 9 | 10 | const Dashboard = async () => { 11 | const { userId } = auth(); 12 | if (!userId) { 13 | return redirect("/"); 14 | } 15 | const user = await clerkClient.users.getUser(userId); 16 | 17 | const { completedCourses, coursesInProgress } = await getDashboardCourses( 18 | userId 19 | ); 20 | const xp = user.publicMetadata.xp || 0; 21 | 22 | return ( 23 |
24 |
25 |

26 | Welcome back {user.firstName?.charAt(0)?.toUpperCase()} 27 | {user.firstName?.slice(1)}! 28 |

29 |

30 | Take a look your learning progress for Today{" "} 31 | {new Date(Date.now()).toLocaleDateString("en-US", { 32 | month: "short", 33 | day: "numeric", 34 | year: "numeric", 35 | })} 36 |

37 |
38 |
39 | 40 |
41 | 47 | 53 | 59 |
60 |
61 |
62 | 63 |
64 |
65 | ); 66 | }; 67 | 68 | export default Dashboard; 69 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/search/_components/Categories.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Category } from '@prisma/client' 4 | import React from 'react' 5 | 6 | import { IconType } from 'react-icons'; 7 | import { FaCode, FaLaptopCode, FaMicrochip, FaMobileScreenButton, FaPaintbrush } from 'react-icons/fa6'; 8 | import CategoryItem from './CategoryItem'; 9 | 10 | const IconMap: Record = { 11 | "Programming": FaCode, 12 | "Web Development": FaLaptopCode, 13 | "Electronics": FaMicrochip, 14 | "Mobile App Development": FaMobileScreenButton, 15 | "Design": FaPaintbrush, 16 | }; 17 | 18 | interface CategoriesProps { 19 | items: Category[]; 20 | } 21 | 22 | const Categories = ({items}: CategoriesProps) => { 23 | return ( 24 |
25 | {items.map((category) => ( 26 | 32 | ))} 33 |
34 | ) 35 | } 36 | 37 | export default Categories -------------------------------------------------------------------------------- /app/(main)/(dashboard)/search/_components/CategoryItem.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import qs from "query-string"; 4 | import { IconType } from "react-icons"; 5 | import { 6 | usePathname, 7 | useRouter, 8 | useSearchParams 9 | } from "next/navigation"; 10 | 11 | import { cn } from "@/lib/utils"; 12 | 13 | interface CategoryItemProps { 14 | label: string; 15 | value: string; 16 | icon?: IconType; 17 | }; 18 | 19 | const CategoryItem = ({ 20 | label, 21 | value, 22 | icon: Icon, 23 | }: CategoryItemProps) => { 24 | const pathname = usePathname(); 25 | const router = useRouter(); 26 | const searchParams = useSearchParams(); 27 | 28 | const currentCategoryId = searchParams.get("categoryId"); 29 | const currentTitle = searchParams.get("title"); 30 | 31 | const isSelected = currentCategoryId === value; 32 | 33 | const onClick = () => { 34 | const url = qs.stringifyUrl({ 35 | url: pathname, 36 | query: { 37 | title: currentTitle, 38 | categoryId: isSelected ? null : value, 39 | } 40 | }, { skipNull: true, skipEmptyString: true }); 41 | 42 | router.push(url); 43 | }; 44 | 45 | return ( 46 | 59 | ) 60 | } 61 | 62 | export default CategoryItem; -------------------------------------------------------------------------------- /app/(main)/(dashboard)/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { getCourses } from "@/actions/get-courses"; 2 | import { auth, clerkClient } from "@clerk/nextjs"; 3 | import { db } from "@/lib/db"; 4 | 5 | import Categories from "./_components/Categories"; 6 | import SearchInput from "@/components/SearchInput"; 7 | import { redirect } from "next/navigation"; 8 | import CoursesList from "@/components/Course/CoursesList"; 9 | 10 | interface SearchPageProps { 11 | searchParams: { 12 | title: string, 13 | categoryId: string 14 | } 15 | } 16 | 17 | const Searchpage = async ({searchParams}: SearchPageProps) => { 18 | const {userId} = auth(); 19 | if(!userId) { 20 | return redirect("/"); 21 | } 22 | 23 | const categories = await db.category.findMany(); 24 | const courses = await getCourses({ 25 | userId: userId, 26 | ...searchParams, 27 | }) 28 | 29 | return ( 30 | <> 31 | 32 |
33 | 34 |
35 | 36 | ); 37 | }; 38 | 39 | export default Searchpage; 40 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/teacher/analytics/_components/Chart.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Card } from "@/components/ui/card"; 4 | import { 5 | BarChart, 6 | Bar, 7 | Rectangle, 8 | XAxis, 9 | YAxis, 10 | CartesianGrid, 11 | Tooltip, 12 | Legend, 13 | ResponsiveContainer, 14 | } from "recharts"; 15 | 16 | interface ChartProps { 17 | data: { 18 | name: string; 19 | total: number; 20 | }[]; 21 | } 22 | 23 | const Chart = ({ data }: ChartProps) => { 24 | return ( 25 | 26 | 27 | 38 | {/* */} 39 | 40 | `TRY ${value}`} fontSize={12}/> 41 | {/* 42 | */} 43 | 48 | 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default Chart; 55 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/teacher/analytics/_components/DataCard.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 2 | import { formatPrice } from "@/lib/formats"; 3 | import React from "react"; 4 | 5 | interface DataCardProps { 6 | value: number; 7 | label: string; 8 | shouldFormat?: boolean; 9 | name?: string; 10 | } 11 | 12 | const DataCard = ({ value, label, shouldFormat, name }: DataCardProps) => { 13 | return ( 14 | 15 | 16 | 17 | 18 | {label} 19 | 20 | 21 | 22 |
23 | {shouldFormat ? formatPrice(value) : value} 24 | {name ? ( 25 |

26 | {name} 27 |

28 | ): ""} 29 |
30 |
31 |
32 | ); 33 | }; 34 | 35 | export default DataCard; 36 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/teacher/analytics/page.tsx: -------------------------------------------------------------------------------- 1 | import { getAnalytics } from '@/actions/get-analytics'; 2 | import { auth } from '@clerk/nextjs' 3 | import { redirect } from 'next/navigation'; 4 | import DataCard from './_components/DataCard'; 5 | import Chart from './_components/Chart'; 6 | import { Card } from '@/components/ui/card'; 7 | 8 | const Analytics = async () => { 9 | const {userId} = auth(); 10 | if(!userId) { 11 | return redirect("/") 12 | } 13 | 14 | const {data, totalRevenue, totalSales} = await getAnalytics(userId); 15 | const totalStudents = totalRevenue; 16 | 17 | return ( 18 |
19 |
20 | 21 | 22 | 23 |
24 | {!totalSales || !totalStudents ? ( 25 | 26 | No Data Found 27 | 28 | ): ( 29 | 30 | )} 31 |
32 | ) 33 | } 34 | 35 | export default Analytics -------------------------------------------------------------------------------- /app/(main)/(dashboard)/teacher/courses/[courseId]/_components/Actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { useState } from "react"; 5 | import { toast } from "sonner"; 6 | import axios from "axios"; 7 | import { useRouter } from "next/navigation"; 8 | import { useConfettiStore } from "@/hooks/use-confetti-store"; 9 | import { Loader2 } from "lucide-react"; 10 | import { LuTrash2 } from "react-icons/lu"; 11 | import ConfirmDelete from "@/components/ConfirmDelete"; 12 | 13 | interface ChapterActionsProps { 14 | disabled: boolean; 15 | courseId: string; 16 | isPublished: boolean; 17 | } 18 | 19 | const Actions = ({ disabled, courseId, isPublished }: ChapterActionsProps) => { 20 | const router = useRouter(); 21 | const [isLoading, setIsLoading] = useState(false); 22 | const confetti = useConfettiStore(); 23 | 24 | const onClick = async () => { 25 | try { 26 | setIsLoading(true); 27 | if (isPublished) { 28 | await axios.patch(`/api/courses/${courseId}/unpublish`); 29 | toast.success("Course unpublished!"); 30 | } else { 31 | await axios.patch(`/api/courses/${courseId}/publish`); 32 | toast.success("Course published!"); 33 | confetti.onOpen(); 34 | } 35 | 36 | router.refresh(); 37 | } catch (error) { 38 | toast.error("Something went wrong!"); 39 | } finally { 40 | setIsLoading(false); 41 | } 42 | }; 43 | 44 | return ( 45 |
46 | 53 | 54 | 65 | 66 |
67 | ); 68 | }; 69 | 70 | export default Actions; 71 | -------------------------------------------------------------------------------- /app/(main)/(dashboard)/teacher/courses/[courseId]/_components/DescriptionForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as z from "zod"; 4 | import axios from "axios"; 5 | import { zodResolver } from "@hookform/resolvers/zod"; 6 | import { useForm } from "react-hook-form"; 7 | import { Button } from "@/components/ui/button"; 8 | import { FaPencilAlt } from "react-icons/fa"; 9 | import { MdOutlineCancel } from "react-icons/md"; 10 | import { useState } from "react"; 11 | import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; 12 | import { Input } from "@/components/ui/input"; 13 | import { BiLoader } from "react-icons/bi"; 14 | import { toast } from "sonner"; 15 | import { useRouter } from "next/navigation"; 16 | import { cn } from "@/lib/utils"; 17 | import { Textarea } from "@/components/ui/textarea"; 18 | import { Course } from "@prisma/client"; 19 | 20 | interface DescriptionFormProps { 21 | initialData: Course; 22 | courseId: string; 23 | } 24 | const formSchema = z.object({ 25 | description: z.string().min(1, { 26 | message: "Description is required", 27 | }), 28 | }); 29 | 30 | const DescriptionForm = ({ initialData, courseId }: DescriptionFormProps) => { 31 | const [isEditing, setIsEditing] = useState(false); 32 | const toggleEdit = () => setIsEditing((current) => !current); 33 | const router = useRouter(); 34 | 35 | const form = useForm>({ 36 | resolver: zodResolver(formSchema), 37 | defaultValues: { 38 | description: initialData?.description || "" 39 | }, 40 | }); 41 | 42 | const { isSubmitting, isValid } = form.formState; 43 | 44 | const onSubmit = async (values: z.infer) => { 45 | try { 46 | console.log(values); 47 | await axios.patch(`/api/courses/${courseId}`, values) 48 | toast.success("Course Updated!"); 49 | toggleEdit(); 50 | router.refresh(); 51 | } catch (error) { 52 | toast.error("Something went wrong"); 53 | } 54 | }; 55 | 56 | return ( 57 |
58 |
59 | 60 | {isSubmitting && } 61 | 62 | Course Description * 63 | 64 | 65 | 78 |
79 | {isEditing ? ( 80 |
81 | 82 | ( 83 | 84 | 85 |