diff --git a/backend/src/routes/enrollments.js b/backend/src/routes/enrollments.js index e727d25..f2de24d 100644 --- a/backend/src/routes/enrollments.js +++ b/backend/src/routes/enrollments.js @@ -83,6 +83,16 @@ router.post('/', wrapAsync(async (req, res) => { res.status(200).send(payload); })); + +router.post('/enroll-course', wrapAsync(async (req, res) => { + const payload = await EnrollmentsService.enrollInCourse( + req.body.courseId, + req.currentUser, + ); + + res.status(200).send(payload); +})); + /** * @swagger * /api/budgets/bulk-import: @@ -383,6 +393,23 @@ router.get('/autocomplete', async (req, res) => { res.status(200).send(payload); }); + +router.get('/my-learning', wrapAsync(async (req, res) => { + const payload = await EnrollmentsService.getMyLearning(req.currentUser); + + res.status(200).send(payload); +})); + +router.post('/:id/toggle-lesson', wrapAsync(async (req, res) => { + const payload = await EnrollmentsService.toggleLessonCompletion( + req.params.id, + req.body.lessonId, + req.currentUser, + ); + + res.status(200).send(payload); +})); + /** * @swagger * /api/enrollments/{id}: diff --git a/backend/src/services/enrollments.js b/backend/src/services/enrollments.js index 7dde01e..357e5af 100644 --- a/backend/src/services/enrollments.js +++ b/backend/src/services/enrollments.js @@ -3,9 +3,8 @@ const EnrollmentsDBApi = require('../db/api/enrollments'); const processFile = require("../middlewares/upload"); const ValidationError = require('./notifications/errors/validation'); const csv = require('csv-parser'); -const axios = require('axios'); -const config = require('../config'); const stream = require('stream'); +const Op = db.Sequelize.Op; @@ -28,9 +27,9 @@ module.exports = class EnrollmentsService { await transaction.rollback(); throw error; } - }; + } - static async bulkImport(req, res, sendInvitationEmails = true, host) { + static async bulkImport(req, res) { const transaction = await db.sequelize.transaction(); try { @@ -95,7 +94,7 @@ module.exports = class EnrollmentsService { await transaction.rollback(); throw error; } - }; + } static async deleteByIds(ids, currentUser) { const transaction = await db.sequelize.transaction(); @@ -113,6 +112,329 @@ module.exports = class EnrollmentsService { } } + + static async getMyLearning(currentUser) { + if (!currentUser || !currentUser.id) { + const error = new Error('Authentication required.'); + error.code = 403; + throw error; + } + + const [courses, lessons, enrollments] = await Promise.all([ + db.courses.findAll({ + where: { status: 'published' }, + include: [ + { + model: db.users, + as: 'instructor', + attributes: ['id', 'firstName', 'lastName', 'email'], + required: false, + }, + ], + order: [ + ['is_featured', 'DESC'], + ['published_at', 'DESC'], + ['title', 'ASC'], + ], + }), + db.lessons.findAll({ + where: { status: 'published' }, + order: [ + ['courseId', 'ASC'], + ['order_index', 'ASC'], + ['title', 'ASC'], + ], + }), + db.enrollments.findAll({ + where: { studentId: currentUser.id }, + order: [ + ['updatedAt', 'DESC'], + ['createdAt', 'DESC'], + ], + }), + ]); + + const enrollmentIds = enrollments.map((item) => item.id); + const completions = enrollmentIds.length + ? await db.lesson_completions.findAll({ + where: { enrollmentId: { [Op.in]: enrollmentIds } }, + order: [['updatedAt', 'DESC']], + }) + : []; + + const plainLessons = lessons.map((item) => item.get({ plain: true })); + const lessonsByCourseId = plainLessons.reduce((accumulator, lesson) => { + if (!accumulator[lesson.courseId]) { + accumulator[lesson.courseId] = []; + } + + accumulator[lesson.courseId].push(lesson); + return accumulator; + }, {}); + + const plainEnrollments = enrollments.map((item) => item.get({ plain: true })); + const enrollmentByCourseId = plainEnrollments.reduce((accumulator, enrollment) => { + accumulator[enrollment.courseId] = enrollment; + return accumulator; + }, {}); + + const plainCompletions = completions.map((item) => item.get({ plain: true })); + const completionByEnrollmentId = plainCompletions.reduce((accumulator, completion) => { + if (!accumulator[completion.enrollmentId]) { + accumulator[completion.enrollmentId] = {}; + } + + accumulator[completion.enrollmentId][completion.lessonId] = completion; + return accumulator; + }, {}); + + const courseCatalog = courses.map((item) => { + const course = item.get({ plain: true }); + const courseLessons = lessonsByCourseId[course.id] || []; + const enrollment = enrollmentByCourseId[course.id] || null; + const courseCompletions = enrollment + ? completionByEnrollmentId[enrollment.id] || {} + : {}; + const completedLessons = courseLessons.filter( + (lesson) => courseCompletions[lesson.id]?.is_completed, + ).length; + const derivedProgress = courseLessons.length + ? Math.round((completedLessons / courseLessons.length) * 100) + : 0; + + return { + ...course, + lessons: courseLessons, + enrollment, + completedLessons, + totalLessons: courseLessons.length, + progressPercent: + enrollment?.progress_percent !== null && + enrollment?.progress_percent !== undefined + ? Number(enrollment.progress_percent) + : derivedProgress, + }; + }); + + return { + courses: courseCatalog, + enrollments: plainEnrollments, + completions: plainCompletions, + stats: { + availableCourses: courseCatalog.length, + enrolledCourses: plainEnrollments.length, + completedLessons: plainCompletions.filter((item) => item.is_completed) + .length, + averageProgress: plainEnrollments.length + ? Math.round( + plainEnrollments.reduce( + (sum, enrollment) => + sum + Number(enrollment.progress_percent || 0), + 0, + ) / plainEnrollments.length, + ) + : 0, + }, + }; + } + + static async enrollInCourse(courseId, currentUser) { + if (!currentUser || !currentUser.id) { + const error = new Error('Authentication required.'); + error.code = 403; + throw error; + } + + if (!courseId) { + const error = new Error('Course is required.'); + error.code = 400; + throw error; + } + + const course = await db.courses.findOne({ + where: { + id: courseId, + status: 'published', + }, + }); + + if (!course) { + const error = new Error('Published course not found.'); + error.code = 404; + throw error; + } + + const existingEnrollment = await db.enrollments.findOne({ + where: { + studentId: currentUser.id, + courseId, + }, + }); + + if (existingEnrollment) { + return { + alreadyEnrolled: true, + enrollment: existingEnrollment.get({ plain: true }), + }; + } + + const enrollment = await db.enrollments.create({ + status: 'active', + enrolled_at: new Date(), + completed_at: null, + progress_percent: 0, + last_activity_at: new Date(), + studentId: currentUser.id, + courseId, + createdById: currentUser.id, + updatedById: currentUser.id, + }); + + return { + alreadyEnrolled: false, + enrollment: enrollment.get({ plain: true }), + }; + } + + static async toggleLessonCompletion(enrollmentId, lessonId, currentUser) { + if (!currentUser || !currentUser.id) { + const error = new Error('Authentication required.'); + error.code = 403; + throw error; + } + + if (!enrollmentId || !lessonId) { + const error = new Error('Enrollment and lesson are required.'); + error.code = 400; + throw error; + } + + const transaction = await db.sequelize.transaction(); + + try { + const enrollment = await db.enrollments.findByPk(enrollmentId, { transaction }); + + if (!enrollment) { + const error = new Error('Enrollment not found.'); + error.code = 404; + throw error; + } + + const isAdministrator = currentUser?.app_role?.name === 'Administrator'; + if (!isAdministrator && enrollment.studentId !== currentUser.id) { + const error = new Error('You can only update your own learning progress.'); + error.code = 403; + throw error; + } + + const lesson = await db.lessons.findOne({ + where: { + id: lessonId, + courseId: enrollment.courseId, + status: 'published', + }, + transaction, + }); + + if (!lesson) { + const error = new Error('Lesson not found for this enrollment.'); + error.code = 404; + throw error; + } + + const now = new Date(); + let completion = await db.lesson_completions.findOne({ + where: { + enrollmentId, + lessonId, + }, + transaction, + }); + + if (completion) { + await completion.update( + { + is_completed: !completion.is_completed, + completed_at: completion.is_completed ? null : now, + last_viewed_at: now, + updatedById: currentUser.id, + }, + { transaction }, + ); + } else { + completion = await db.lesson_completions.create( + { + is_completed: true, + completed_at: now, + last_viewed_at: now, + time_spent_seconds: 0, + enrollmentId, + lessonId, + createdById: currentUser.id, + updatedById: currentUser.id, + }, + { transaction }, + ); + } + + const totalLessons = await db.lessons.count({ + where: { + courseId: enrollment.courseId, + status: 'published', + }, + transaction, + }); + + const completedLessons = await db.lesson_completions.count({ + where: { + enrollmentId, + is_completed: true, + }, + include: [ + { + model: db.lessons, + as: 'lesson', + attributes: [], + required: true, + where: { + courseId: enrollment.courseId, + status: 'published', + }, + }, + ], + transaction, + }); + + const progressPercent = totalLessons + ? Math.round((completedLessons / totalLessons) * 100) + : 0; + + await enrollment.update( + { + progress_percent: progressPercent, + status: progressPercent === 100 ? 'completed' : 'active', + completed_at: progressPercent === 100 ? now : null, + last_activity_at: now, + updatedById: currentUser.id, + }, + { transaction }, + ); + + await transaction.commit(); + + return { + completion: completion.get({ plain: true }), + enrollment: enrollment.get({ plain: true }), + progress_percent: progressPercent, + completed_lessons: completedLessons, + total_lessons: totalLessons, + }; + } catch (error) { + await transaction.rollback(); + throw error; + } + } + static async remove(id, currentUser) { const transaction = await db.sequelize.transaction(); @@ -132,7 +454,6 @@ module.exports = class EnrollmentsService { } } - -}; +} diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index 68ead53..6a55ee1 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -8,6 +8,15 @@ const menuAside: MenuAsideItem[] = [ label: 'Dashboard', }, + { + href: '/learning-hub', + label: 'Learning Hub', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + icon: 'mdiSchoolOutline' in icon ? icon['mdiSchoolOutline' as keyof typeof icon] : icon.mdiViewDashboardOutline, + permissions: 'READ_ENROLLMENTS' + }, + { href: '/users/users-list', label: 'Users', diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 471f2e8..f25c912 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -1,166 +1,242 @@ - -import React, { useEffect, useState } from 'react'; -import type { ReactElement } from 'react'; +import { + mdiBookOpenPageVariant, + mdiChartTimelineVariant, + mdiCheckCircleOutline, + mdiNotebookOutline, + mdiSchoolOutline, +} from '@mdi/js'; import Head from 'next/head'; import Link from 'next/link'; +import React from 'react'; +import type { ReactElement } from 'react'; import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; import CardBox from '../components/CardBox'; -import SectionFullScreen from '../components/SectionFullScreen'; import LayoutGuest from '../layouts/Guest'; -import BaseDivider from '../components/BaseDivider'; -import BaseButtons from '../components/BaseButtons'; import { getPageTitle } from '../config'; -import { useAppSelector } from '../stores/hooks'; -import CardBoxComponentTitle from "../components/CardBoxComponentTitle"; -import { getPexelsImage, getPexelsVideo } from '../helpers/pexels'; +const featureCards = [ + { + title: 'Courses that feel structured', + text: 'Publish polished learning paths with clear descriptions, featured content, and intentional lesson order.', + icon: mdiBookOpenPageVariant, + }, + { + title: 'Lessons made for momentum', + text: 'Deliver text and markdown lessons that are fast to author, easy to read, and simple to extend later.', + icon: mdiNotebookOutline, + }, + { + title: 'Progress learners can feel', + text: 'Enrollment, lesson completion, and course progress work together so every session shows visible movement.', + icon: mdiCheckCircleOutline, + }, +]; -export default function Starter() { - const [illustrationImage, setIllustrationImage] = useState({ - src: undefined, - photographer: undefined, - photographer_url: undefined, - }) - const [illustrationVideo, setIllustrationVideo] = useState({video_files: []}) - const [contentType, setContentType] = useState('video'); - const [contentPosition, setContentPosition] = useState('left'); - const textColor = useAppSelector((state) => state.style.linkColor); - - const title = 'Instructor Student LMS' - - // Fetch Pexels image/video - useEffect(() => { - async function fetchData() { - const image = await getPexelsImage(); - const video = await getPexelsVideo(); - setIllustrationImage(image); - setIllustrationVideo(video); - } - fetchData(); - }, []); - - const imageBlock = (image) => ( -
-
- - Photo by {image?.photographer} on Pexels - -
-
- ); - - const videoBlock = (video) => { - if (video?.video_files?.length > 0) { - return ( -
- -
- - Video by {video.user.name} on Pexels - -
-
) - } - }; +const workflowSteps = [ + { + eyebrow: 'Instructor flow', + title: 'Create a course', + text: 'Set up the course shell, assign an instructor, add a concise description, then publish when it is ready.', + }, + { + eyebrow: 'Student flow', + title: 'Enroll in one click', + text: 'Learners discover published courses, join instantly, and open the lesson reader without jumping between pages.', + }, + { + eyebrow: 'Operations view', + title: 'Track learning progress', + text: 'Admins and instructors can review enrollments while students see real progress grow lesson by lesson.', + }, +]; +export default function HomePage() { return ( -
+ <> - {getPageTitle('Starter Page')} + {getPageTitle('Instructor Student LMS')} + - -
- {contentType === 'image' && contentPosition !== 'background' - ? imageBlock(illustrationImage) - : null} - {contentType === 'video' && contentPosition !== 'background' - ? videoBlock(illustrationVideo) - : null} -
- - - -
-

This is a React.js/Node.js app generated by the Flatlogic Web App Generator

-

For guides and documentation please check - your local README.md and the Flatlogic documentation

+
+
+
+
+

+ Instructor Student LMS +

+

A focused LMS for instructors, students, and learning operations.

- - - - - - +
+ + +
+
-
- -
-

© 2026 {title}. All rights reserved

- - Privacy Policy - -
-
+
+
+
+
+ + Initial MVP slice live + +
+

+ A clean LMS for publishing courses and helping students finish what they start. +

+

+ Built around the core journey that matters first: instructors publish courses and lessons, + students enroll, read, and watch progress update in real time. +

+
+
+ +
+ + + +
+ +
+
+

Roles

+

3

+

Admin, Instructor, and Learner workflows are all accounted for.

+
+
+

Lesson format

+

Text / Markdown

+

Fast authoring now, easy to extend later with media attachments.

+
+
+

What is new

+

Learning Hub

+

Enroll, continue lessons, and see progress move without raw CRUD screens.

+
+
+
+ + +
+
+
+

Thin slice

+

Student journey

+
+
+ +
+
+ +
+
+

1. Browse published courses

+

Students see a focused catalog instead of generic tables.

+
+
+

2. Enroll instantly

+

One click creates the enrollment and opens the lesson path.

+
+
+

3. Complete lessons

+

Completion updates course progress automatically for a real sense of momentum.

+
+
+
+
+
+ +
+ {featureCards.map((card) => ( + +
+
+ +
+
+

{card.title}

+

{card.text}

+
+
+
+ ))} +
+ +
+
+

Workflow first

+

+ The first delivery is an end-to-end loop, not just a prettier admin. +

+

+ Core CRUD already exists. This iteration turns that foundation into a usable product slice that both students and instructors can understand immediately. +

+
+ +
+ {workflowSteps.map((step, index) => ( + +
+
+ + {step.eyebrow} + + + 0{index + 1} + +
+
+

{step.title}

+

{step.text}

+
+
+
+ ))} +
+
+ +
+
+
+

Ready to explore

+

+ Jump into the learner workflow or open the admin interface. +

+

+ The app now has a public-facing entry, a direct login path, and a branded route into the new learning hub. +

+
+
+ + +
+
+
+
+ +
+
+

© 2026 Instructor Student LMS. Built for modern course publishing and learner progress.

+
+ + Privacy Policy + + + Login + +
+
+
+
+ ); } -Starter.getLayout = function getLayout(page: ReactElement) { +HomePage.getLayout = function getLayout(page: ReactElement) { return {page}; }; - diff --git a/frontend/src/pages/learning-hub.tsx b/frontend/src/pages/learning-hub.tsx new file mode 100644 index 0000000..8beb478 --- /dev/null +++ b/frontend/src/pages/learning-hub.tsx @@ -0,0 +1,723 @@ +import { + mdiBookOpenPageVariant, + mdiChartTimelineVariant, + mdiCheckCircleOutline, + mdiNotebookOutline, + mdiSchoolOutline, +} from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { + ReactElement, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import LoadingSpinner from '../components/LoadingSpinner'; +import NotificationBar from '../components/NotificationBar'; +import SectionMain from '../components/SectionMain'; +import SectionTitleLineWithButton from '../components/SectionTitleLineWithButton'; +import { getPageTitle } from '../config'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { hasPermission } from '../helpers/userPermissions'; +import { useAppSelector } from '../stores/hooks'; + +type Lesson = { + id: string; + title: string; + content_markdown: string; + order_index: number; + estimated_minutes: number; + is_preview: boolean; +}; + +type Enrollment = { + id: string; + courseId: string; + progress_percent: number | string; + status: string; +}; + +type Completion = { + id: string; + lessonId: string; + enrollmentId: string; + is_completed: boolean; +}; + +type Course = { + id: string; + title: string; + short_description: string; + description: string; + is_featured: boolean; + instructor?: { + firstName?: string; + lastName?: string; + email?: string; + }; + lessons: Lesson[]; + enrollment: Enrollment | null; + completedLessons: number; + totalLessons: number; + progressPercent: number; +}; + +type LearningPayload = { + courses: Course[]; + completions: Completion[]; + stats: { + availableCourses: number; + enrolledCourses: number; + completedLessons: number; + averageProgress: number; + }; +}; + +const StatCard = ({ label, value, hint }: { label: string; value: string | number; hint: string }) => ( + +
+

+ {label} +

+

{value}

+

{hint}

+
+
+); + +const ProgressBar = ({ value }: { value: number }) => ( +
+
+
+); + +const formatInstructor = (course: Course) => { + const fullName = `${course.instructor?.firstName || ''} ${course.instructor?.lastName || ''}`.trim(); + + if (fullName) { + return fullName; + } + + return course.instructor?.email || 'Instructor'; +}; + +const LearningHub = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [courses, setCourses] = useState([]); + const [completions, setCompletions] = useState([]); + const [stats, setStats] = useState({ + availableCourses: 0, + enrolledCourses: 0, + completedLessons: 0, + averageProgress: 0, + }); + const [selectedCourseId, setSelectedCourseId] = useState(''); + const [selectedLessonId, setSelectedLessonId] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [pendingCourseId, setPendingCourseId] = useState(''); + const [pendingLessonId, setPendingLessonId] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const [errorMessage, setErrorMessage] = useState(''); + + const canEnroll = hasPermission(currentUser, 'CREATE_ENROLLMENTS'); + const canManageCourses = hasPermission(currentUser, ['CREATE_COURSES', 'CREATE_LESSONS']); + + const fetchLearningHub = useCallback(async (preferredCourseId?: string) => { + setIsRefreshing(true); + + try { + const response = await axios.get('/enrollments/my-learning'); + const payload = response.data; + const nextCourses = payload.courses || []; + + setCourses(nextCourses); + setCompletions(payload.completions || []); + setStats(payload.stats); + setErrorMessage(''); + + const resolvedCourseId = + preferredCourseId && nextCourses.some((course) => course.id === preferredCourseId) + ? preferredCourseId + : nextCourses.find((course) => course.enrollment)?.id || nextCourses[0]?.id || ''; + + setSelectedCourseId(resolvedCourseId); + } catch (error) { + console.error('Failed to load learning hub:', error); + setErrorMessage('We could not load your learning hub right now. Please refresh and try again.'); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + fetchLearningHub(); + }, [fetchLearningHub]); + + const activeCourse = useMemo( + () => courses.find((course) => course.id === selectedCourseId) || courses[0] || null, + [courses, selectedCourseId], + ); + + useEffect(() => { + if (!activeCourse) { + setSelectedLessonId(''); + return; + } + + const lessonExists = activeCourse.lessons.some((lesson) => lesson.id === selectedLessonId); + if (!lessonExists) { + setSelectedLessonId(activeCourse.lessons[0]?.id || ''); + } + }, [activeCourse, selectedLessonId]); + + const activeLesson = useMemo( + () => activeCourse?.lessons.find((lesson) => lesson.id === selectedLessonId) || null, + [activeCourse, selectedLessonId], + ); + + const activeEnrollment = activeCourse?.enrollment || null; + + const completionLookup = useMemo(() => { + if (!activeEnrollment) { + return {} as Record; + } + + return completions.reduce((accumulator, completion) => { + if (completion.enrollmentId === activeEnrollment.id) { + accumulator[completion.lessonId] = completion; + } + + return accumulator; + }, {} as Record); + }, [activeEnrollment, completions]); + + const enrolledCourses = courses.filter((course) => Boolean(course.enrollment)); + const availableCourses = courses.filter((course) => !course.enrollment); + + const handleEnroll = async (courseId: string) => { + setPendingCourseId(courseId); + setSuccessMessage(''); + setErrorMessage(''); + + try { + const response = await axios.post('/enrollments/enroll-course', { courseId }); + const message = response.data?.alreadyEnrolled + ? 'You are already enrolled in this course.' + : 'Enrollment confirmed. Your first lesson is ready.'; + + setSuccessMessage(message); + await fetchLearningHub(courseId); + } catch (error) { + console.error('Failed to enroll in course:', error); + setErrorMessage('We could not enroll you in that course. Please try again.'); + } finally { + setPendingCourseId(''); + } + }; + + const handleToggleLesson = async (lessonId: string) => { + if (!activeEnrollment) { + return; + } + + setPendingLessonId(lessonId); + setSuccessMessage(''); + setErrorMessage(''); + + try { + const response = await axios.post(`/enrollments/${activeEnrollment.id}/toggle-lesson`, { + lessonId, + }); + const isCompleted = response.data?.completion?.is_completed; + + setSuccessMessage( + isCompleted + ? 'Lesson marked complete — your progress just moved forward.' + : 'Lesson marked incomplete so you can revisit it later.', + ); + await fetchLearningHub(activeCourse?.id); + } catch (error) { + console.error('Failed to update lesson progress:', error); + setErrorMessage('We could not update this lesson right now. Please try again.'); + } finally { + setPendingLessonId(''); + } + }; + + if (isLoading) { + return ( + <> + + {getPageTitle('Learning Hub')} + + + + {''} + + + + + + + ); + } + + return ( + <> + + {getPageTitle('Learning Hub')} + + + + + {''} + + + +
+
+ + Student MVP slice + +
+

+ Enroll, read lessons, and watch progress update in one clean flow. +

+

+ This first iteration turns the LMS data model into a usable learner journey: + discover published courses, join instantly, and track completion lesson by lesson. +

+
+
+ + +
+
+
+
+

Ready right now

+

{stats.enrolledCourses}

+

Courses already active for your account.

+
+
+

Momentum

+

{stats.averageProgress}%

+

Average progress across enrolled courses.

+
+
+
+
+ + {successMessage ? ( + + {successMessage} + + ) : null} + + {errorMessage ? ( + + {errorMessage} + + ) : null} + +
+ + + + +
+ +
+
+ +
+
+

+ Course catalog +

+

Choose your next course

+
+ {isRefreshing ? ( + Refreshing… + ) : null} +
+ + {courses.length ? ( +
+ {courses.map((course) => { + const isSelected = activeCourse?.id === course.id; + const isEnrolled = Boolean(course.enrollment); + const buttonLabel = isEnrolled ? 'Continue course' : 'Enroll now'; + + return ( +
setSelectedCourseId(course.id)} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setSelectedCourseId(course.id); + } + }} + className={`w-full rounded-2xl border p-5 text-left transition duration-150 focus:outline-none focus:ring ${ + isSelected + ? 'border-[#2563EB] bg-[#EFF6FF] shadow-sm ring-blue-200' + : 'border-slate-200 bg-white hover:border-slate-300 hover:shadow-sm ring-slate-200' + }`} + > +
+
+
+

{course.title}

+ {course.is_featured ? ( + + Featured + + ) : null} + {isEnrolled ? ( + + Enrolled + + ) : null} +
+

{course.short_description}

+
+
+ +
+
+ +
+
+

Instructor

+

{formatInstructor(course)}

+
+
+

Lessons

+

{course.totalLessons} text lessons

+
+
+ +
+
+ Progress + {course.progressPercent}% +
+ +
+ +
+ {isEnrolled ? ( + + + {course.completedLessons}/{course.totalLessons} lessons completed + + ) : ( + + + {course.totalLessons} lessons ready to start + + )} + + {!isEnrolled && canEnroll ? ( + { + event.stopPropagation(); + handleEnroll(course.id); + }} + disabled={pendingCourseId === course.id} + /> + ) : ( + { + event.stopPropagation(); + setSelectedCourseId(course.id); + }} + /> + )} +
+
+ ); + })} +
+ ) : ( +
+ No published courses are available yet. Create or publish a course from the admin area + to activate this learning flow. +
+ )} +
+ + {canManageCourses ? ( + +
+
+

+ Instructor quick actions +

+

Publish a course in three moves

+
+
+
+

1. Create the course shell

+

Set the title, description, instructor, and featured status.

+
+
+

2. Add ordered text lessons

+

Use markdown-friendly lesson content and estimated reading time.

+
+
+

3. Watch enrollments and completion grow

+

The learner hub now turns those entities into a guided student experience.

+
+
+
+ + +
+
+
+ ) : null} +
+ +
+ + {activeCourse ? ( +
+
+
+
+
+ + {activeEnrollment ? 'In progress' : 'Preview'} + + + {activeCourse.totalLessons} lessons + +
+
+

{activeCourse.title}

+

+ {activeCourse.description || activeCourse.short_description} +

+
+
+ + {!activeEnrollment && canEnroll ? ( + handleEnroll(activeCourse.id)} + disabled={pendingCourseId === activeCourse.id} + /> + ) : null} +
+ +
+
+ Course progress + {activeCourse.progressPercent}% +
+ +
+
+ +
+
+
+

Lesson path

+ + {activeCourse.completedLessons}/{activeCourse.totalLessons} completed + +
+ + {activeCourse.lessons.length ? ( +
+ {activeCourse.lessons.map((lesson, index) => { + const completion = completionLookup[lesson.id]; + const isSelected = activeLesson?.id === lesson.id; + const isCompleted = Boolean(completion?.is_completed); + + return ( + + ); + })} +
+ ) : ( +
+ This course is published, but there are no published lessons attached yet. +
+ )} +
+ +
+ {activeLesson ? ( +
+
+
+

+ Lesson reader +

+

{activeLesson.title}

+

+ Estimated time: {activeLesson.estimated_minutes || 5} minutes +

+
+ {activeEnrollment ? ( + handleToggleLesson(activeLesson.id)} + disabled={pendingLessonId === activeLesson.id} + /> + ) : null} +
+ + {!activeEnrollment ? ( +
+ Enroll in this course to unlock completion tracking and persist your progress. +
+ ) : null} + +
+
+ {activeLesson.content_markdown || 'Lesson content has not been added yet.'} +
+
+
+ ) : ( +
+
+ +
+

Choose a lesson to begin

+

+ Select a lesson from the path to open the reader and record completion. +

+
+ )} +
+
+
+ ) : ( +
+
+ +
+

Your learning space is ready

+

+ Publish the first course from the admin area, then come back here to enroll and track lesson completion. +

+
+ + +
+
+ )} +
+ + +
+
+

Create

+

Instructors continue using the existing course and lesson CRUD pages.

+
+
+

Enroll

+

Learners join a published course with one click from the catalog.

+
+
+

Track progress

+

Lesson completion updates enrollment progress and completion status automatically.

+
+
+
+
+
+
+ + ); +}; + +LearningHub.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default LearningHub;