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) => (
-
- );
-
- const videoBlock = (video) => {
- if (video?.video_files?.length > 0) {
- return (
-
-
-
- Your browser does not support the video tag.
-
-
-
)
- }
- };
+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.
+
+
+
+
+
+
+
+
+
+
+
+
+ >
);
}
-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 (
+
setSelectedLessonId(lesson.id)}
+ className={`w-full rounded-2xl border p-4 text-left transition duration-150 ${
+ isSelected
+ ? 'border-[#2563EB] bg-[#EFF6FF]'
+ : 'border-slate-200 bg-white hover:border-slate-300'
+ }`}
+ >
+
+
+ {isCompleted ? '✓' : index + 1}
+
+
+
+
+
{lesson.title}
+
+ {lesson.estimated_minutes || 5} min read
+
+
+ {lesson.is_preview ? (
+
+ Preview
+
+ ) : null}
+
+
+
+
+ );
+ })}
+
+ ) : (
+
+ 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;