This commit is contained in:
Flatlogic Bot 2026-04-29 12:00:40 +00:00
parent 2541ab1c8e
commit 3ab00bfa3b
7 changed files with 1310 additions and 156 deletions

View File

@ -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}:

View File

@ -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 {
}
}
};
}

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
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 (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<>
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Instructor Student LMS')}</title>
<meta
name='description'
content='A modern LMS for course publishing, lesson delivery, enrollment, and progress tracking.'
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your Instructor Student LMS app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
<div className='min-h-screen bg-[radial-gradient(circle_at_top,_rgba(37,99,235,0.18),_transparent_35%),linear-gradient(180deg,#F8FAFC_0%,#EEF2FF_42%,#FFFFFF_100%)] text-slate-900'>
<div className='mx-auto max-w-7xl px-6 py-6 lg:px-10'>
<div className='flex flex-wrap items-center justify-between gap-4 rounded-full border border-white/80 bg-white/80 px-5 py-3 shadow-sm backdrop-blur'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.24em] text-[#2563EB]'>
Instructor Student LMS
</p>
<p className='mt-1 text-sm text-slate-500'>A focused LMS for instructors, students, and learning operations.</p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/login' color='info' label='Login' />
<BaseButton href='/dashboard' color='info' outline label='Admin interface' />
</div>
</div>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</div>
<main className='mx-auto max-w-7xl px-6 pb-16 lg:px-10'>
<section className='grid gap-10 py-10 lg:grid-cols-[1.1fr_0.9fr] lg:py-16'>
<div className='space-y-8'>
<div className='space-y-5'>
<span className='inline-flex items-center rounded-full border border-[#BFDBFE] bg-white px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-[#1D4ED8] shadow-sm'>
Initial MVP slice live
</span>
<div className='space-y-4'>
<h1 className='max-w-3xl text-5xl font-semibold tracking-tight text-slate-950 md:text-6xl'>
A clean LMS for publishing courses and helping students finish what they start.
</h1>
<p className='max-w-2xl text-lg leading-8 text-slate-600'>
Built around the core journey that matters first: instructors publish courses and lessons,
students enroll, read, and watch progress update in real time.
</p>
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/learning-hub' color='info' label='Open learning hub' />
<BaseButton href='/login' color='info' outline label='Sign in' />
<BaseButton href='/dashboard' color='whiteDark' label='Go to admin' />
</div>
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>Roles</p>
<p className='mt-3 text-2xl font-semibold text-slate-950'>3</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Admin, Instructor, and Learner workflows are all accounted for.</p>
</div>
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>Lesson format</p>
<p className='mt-3 text-2xl font-semibold text-slate-950'>Text / Markdown</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Fast authoring now, easy to extend later with media attachments.</p>
</div>
<div className='rounded-3xl border border-slate-200 bg-white p-5 shadow-sm'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>What is new</p>
<p className='mt-3 text-2xl font-semibold text-slate-950'>Learning Hub</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Enroll, continue lessons, and see progress move without raw CRUD screens.</p>
</div>
</div>
</div>
<CardBox className='overflow-hidden border border-slate-200 bg-slate-950 text-white shadow-2xl'>
<div className='rounded-3xl bg-[radial-gradient(circle_at_top_right,_rgba(59,130,246,0.45),_transparent_25%),radial-gradient(circle_at_bottom_left,_rgba(20,184,166,0.35),_transparent_28%),linear-gradient(180deg,#0F172A_0%,#111827_100%)] p-8'>
<div className='flex items-center justify-between'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-sky-200'>Thin slice</p>
<h2 className='mt-2 text-3xl font-semibold'>Student journey</h2>
</div>
<div className='rounded-2xl bg-white/10 p-4 text-sky-100'>
<BaseIcon path={mdiSchoolOutline} size={30} />
</div>
</div>
<div className='mt-8 space-y-4'>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm font-semibold'>1. Browse published courses</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Students see a focused catalog instead of generic tables.</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm font-semibold'>2. Enroll instantly</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>One click creates the enrollment and opens the lesson path.</p>
</div>
<div className='rounded-2xl border border-white/10 bg-white/5 p-4'>
<p className='text-sm font-semibold'>3. Complete lessons</p>
<p className='mt-2 text-sm leading-6 text-slate-300'>Completion updates course progress automatically for a real sense of momentum.</p>
</div>
</div>
</div>
</CardBox>
</section>
<section className='grid gap-5 py-6 md:grid-cols-3'>
{featureCards.map((card) => (
<CardBox key={card.title} className='border border-slate-200 shadow-sm'>
<div className='space-y-4'>
<div className='inline-flex rounded-2xl bg-[#EEF2FF] p-3 text-[#3730A3]'>
<BaseIcon path={card.icon} size={24} />
</div>
<div>
<h3 className='text-xl font-semibold text-slate-950'>{card.title}</h3>
<p className='mt-2 text-sm leading-7 text-slate-500'>{card.text}</p>
</div>
</div>
</CardBox>
))}
</section>
<section className='py-14'>
<div className='mb-8 max-w-3xl'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#2563EB]'>Workflow first</p>
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-950 md:text-4xl'>
The first delivery is an end-to-end loop, not just a prettier admin.
</h2>
<p className='mt-3 text-base leading-8 text-slate-600'>
Core CRUD already exists. This iteration turns that foundation into a usable product slice that both students and instructors can understand immediately.
</p>
</div>
<div className='grid gap-5 lg:grid-cols-3'>
{workflowSteps.map((step, index) => (
<CardBox key={step.title} className='border border-slate-200 shadow-sm'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
{step.eyebrow}
</span>
<span className='rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700'>
0{index + 1}
</span>
</div>
<div>
<h3 className='text-xl font-semibold text-slate-950'>{step.title}</h3>
<p className='mt-2 text-sm leading-7 text-slate-500'>{step.text}</p>
</div>
</div>
</CardBox>
))}
</div>
</section>
<section className='rounded-[2rem] border border-slate-200 bg-white px-6 py-8 shadow-sm lg:px-10'>
<div className='flex flex-wrap items-center justify-between gap-5'>
<div className='max-w-2xl'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-[#2563EB]'>Ready to explore</p>
<h2 className='mt-3 text-3xl font-semibold tracking-tight text-slate-950'>
Jump into the learner workflow or open the admin interface.
</h2>
<p className='mt-3 text-base leading-8 text-slate-600'>
The app now has a public-facing entry, a direct login path, and a branded route into the new learning hub.
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/learning-hub' color='info' label='Launch learning hub' />
<BaseButton href='/dashboard' color='info' outline label='Admin interface' />
</div>
</div>
</section>
</main>
<footer className='border-t border-white/70 bg-white/70 backdrop-blur'>
<div className='mx-auto flex max-w-7xl flex-col gap-4 px-6 py-6 text-sm text-slate-500 md:flex-row md:items-center md:justify-between lg:px-10'>
<p>© 2026 Instructor Student LMS. Built for modern course publishing and learner progress.</p>
<div className='flex gap-4'>
<Link className='hover:text-slate-900' href='/privacy-policy'>
Privacy Policy
</Link>
<Link className='hover:text-slate-900' href='/login'>
Login
</Link>
</div>
</div>
</footer>
</div>
</>
);
}
Starter.getLayout = function getLayout(page: ReactElement) {
HomePage.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};

View File

@ -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 }) => (
<CardBox className='border border-slate-200 shadow-sm'>
<div className='space-y-2'>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
{label}
</p>
<p className='text-3xl font-semibold text-slate-900'>{value}</p>
<p className='text-sm leading-6 text-slate-500'>{hint}</p>
</div>
</CardBox>
);
const ProgressBar = ({ value }: { value: number }) => (
<div className='h-2 overflow-hidden rounded-full bg-slate-100'>
<div
className='h-full rounded-full bg-gradient-to-r from-[#2563EB] via-[#4F46E5] to-[#14B8A6] transition-all duration-300'
style={{ width: `${value}%` }}
/>
</div>
);
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<Course[]>([]);
const [completions, setCompletions] = useState<Completion[]>([]);
const [stats, setStats] = useState<LearningPayload['stats']>({
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<LearningPayload>('/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<string, Completion>;
}
return completions.reduce((accumulator, completion) => {
if (completion.enrollmentId === activeEnrollment.id) {
accumulator[completion.lessonId] = completion;
}
return accumulator;
}, {} as Record<string, Completion>);
}, [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 (
<>
<Head>
<title>{getPageTitle('Learning Hub')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiSchoolOutline}
title='Learning Hub'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox>
<LoadingSpinner />
</CardBox>
</SectionMain>
</>
);
}
return (
<>
<Head>
<title>{getPageTitle('Learning Hub')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiSchoolOutline} title='Learning Hub' main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-6 overflow-hidden border border-slate-200 bg-gradient-to-br from-[#0F172A] via-[#1E1B4B] to-[#0F766E] text-white shadow-xl'>
<div className='grid gap-8 lg:grid-cols-[1.2fr_0.8fr]'>
<div className='space-y-5'>
<span className='inline-flex items-center rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.2em] text-sky-100'>
Student MVP slice
</span>
<div className='space-y-3'>
<h2 className='text-3xl font-semibold tracking-tight md:text-4xl'>
Enroll, read lessons, and watch progress update in one clean flow.
</h2>
<p className='max-w-2xl text-sm leading-7 text-slate-200 md:text-base'>
This first iteration turns the LMS data model into a usable learner journey:
discover published courses, join instantly, and track completion lesson by lesson.
</p>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/courses/courses-list' color='white' label='Browse admin courses' />
<BaseButton href='/enrollments/enrollments-list' color='whiteDark' label='Review enrollments' />
</div>
</div>
<div className='grid gap-3 md:grid-cols-2 lg:grid-cols-1'>
<div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-sky-100'>Ready right now</p>
<p className='mt-2 text-2xl font-semibold'>{stats.enrolledCourses}</p>
<p className='mt-1 text-sm text-slate-200'>Courses already active for your account.</p>
</div>
<div className='rounded-2xl border border-white/15 bg-white/10 p-4'>
<p className='text-xs uppercase tracking-[0.2em] text-sky-100'>Momentum</p>
<p className='mt-2 text-2xl font-semibold'>{stats.averageProgress}%</p>
<p className='mt-1 text-sm text-slate-200'>Average progress across enrolled courses.</p>
</div>
</div>
</div>
</CardBox>
{successMessage ? (
<NotificationBar color='success'>
{successMessage}
</NotificationBar>
) : null}
{errorMessage ? (
<NotificationBar color='danger'>
{errorMessage}
</NotificationBar>
) : null}
<div className='mb-6 grid gap-4 lg:grid-cols-4'>
<StatCard
label='Published courses'
value={stats.availableCourses}
hint='Only published courses appear here so the learner journey stays focused.'
/>
<StatCard
label='Enrolled'
value={stats.enrolledCourses}
hint='Your active course list is always one click away.'
/>
<StatCard
label='Lessons done'
value={stats.completedLessons}
hint='Lesson completion drives the progress bar and course status.'
/>
<StatCard
label='Average progress'
value={`${stats.averageProgress}%`}
hint='A fast pulse-check for current learning momentum.'
/>
</div>
<div className='grid gap-6 xl:grid-cols-[0.95fr_1.45fr]'>
<div className='space-y-6'>
<CardBox className='border border-slate-200 shadow-sm'>
<div className='mb-5 flex items-start justify-between gap-3'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
Course catalog
</p>
<h3 className='mt-2 text-xl font-semibold text-slate-900'>Choose your next course</h3>
</div>
{isRefreshing ? (
<span className='text-xs font-medium text-slate-500'>Refreshing</span>
) : null}
</div>
{courses.length ? (
<div className='space-y-4'>
{courses.map((course) => {
const isSelected = activeCourse?.id === course.id;
const isEnrolled = Boolean(course.enrollment);
const buttonLabel = isEnrolled ? 'Continue course' : 'Enroll now';
return (
<div
key={course.id}
role='button'
tabIndex={0}
onClick={() => 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'
}`}
>
<div className='flex items-start justify-between gap-4'>
<div className='space-y-2'>
<div className='flex flex-wrap items-center gap-2'>
<h4 className='text-lg font-semibold text-slate-900'>{course.title}</h4>
{course.is_featured ? (
<span className='rounded-full bg-[#DBEAFE] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-[#1D4ED8]'>
Featured
</span>
) : null}
{isEnrolled ? (
<span className='rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700'>
Enrolled
</span>
) : null}
</div>
<p className='text-sm leading-6 text-slate-600'>{course.short_description}</p>
</div>
<div className='rounded-xl bg-slate-100 p-3 text-slate-700'>
<BaseIcon path={mdiBookOpenPageVariant} size={22} />
</div>
</div>
<div className='mt-4 grid gap-3 text-sm text-slate-500 md:grid-cols-2'>
<div>
<p className='font-medium text-slate-700'>Instructor</p>
<p>{formatInstructor(course)}</p>
</div>
<div>
<p className='font-medium text-slate-700'>Lessons</p>
<p>{course.totalLessons} text lessons</p>
</div>
</div>
<div className='mt-4 space-y-2'>
<div className='flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>
<span>Progress</span>
<span>{course.progressPercent}%</span>
</div>
<ProgressBar value={course.progressPercent} />
</div>
<div className='mt-4 flex flex-wrap items-center gap-3'>
{isEnrolled ? (
<span className='inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600'>
<BaseIcon path={mdiCheckCircleOutline} size={16} />
{course.completedLessons}/{course.totalLessons} lessons completed
</span>
) : (
<span className='inline-flex items-center gap-2 rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600'>
<BaseIcon path={mdiNotebookOutline} size={16} />
{course.totalLessons} lessons ready to start
</span>
)}
{!isEnrolled && canEnroll ? (
<BaseButton
color='info'
label={pendingCourseId === course.id ? 'Enrolling…' : buttonLabel}
onClick={(event) => {
event.stopPropagation();
handleEnroll(course.id);
}}
disabled={pendingCourseId === course.id}
/>
) : (
<BaseButton
color='info'
outline={!isEnrolled}
label={buttonLabel}
onClick={(event) => {
event.stopPropagation();
setSelectedCourseId(course.id);
}}
/>
)}
</div>
</div>
);
})}
</div>
) : (
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-6 text-sm leading-6 text-slate-500'>
No published courses are available yet. Create or publish a course from the admin area
to activate this learning flow.
</div>
)}
</CardBox>
{canManageCourses ? (
<CardBox className='border border-slate-200 shadow-sm'>
<div className='space-y-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
Instructor quick actions
</p>
<h3 className='mt-2 text-xl font-semibold text-slate-900'>Publish a course in three moves</h3>
</div>
<div className='grid gap-3'>
<div className='rounded-2xl border border-slate-200 p-4'>
<p className='font-semibold text-slate-900'>1. Create the course shell</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>Set the title, description, instructor, and featured status.</p>
</div>
<div className='rounded-2xl border border-slate-200 p-4'>
<p className='font-semibold text-slate-900'>2. Add ordered text lessons</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>Use markdown-friendly lesson content and estimated reading time.</p>
</div>
<div className='rounded-2xl border border-slate-200 p-4'>
<p className='font-semibold text-slate-900'>3. Watch enrollments and completion grow</p>
<p className='mt-1 text-sm leading-6 text-slate-500'>The learner hub now turns those entities into a guided student experience.</p>
</div>
</div>
<div className='flex flex-wrap gap-3'>
<BaseButton href='/courses/courses-new' color='info' label='Create course' />
<BaseButton href='/lessons/lessons-new' color='info' outline label='Add lesson' />
</div>
</div>
</CardBox>
) : null}
</div>
<div className='space-y-6'>
<CardBox className='border border-slate-200 shadow-sm'>
{activeCourse ? (
<div className='space-y-6'>
<div className='rounded-3xl bg-gradient-to-br from-[#EEF2FF] via-white to-[#ECFEFF] p-6'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div className='space-y-3'>
<div className='flex flex-wrap items-center gap-2'>
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 shadow-sm'>
{activeEnrollment ? 'In progress' : 'Preview'}
</span>
<span className='rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-slate-500 shadow-sm'>
{activeCourse.totalLessons} lessons
</span>
</div>
<div>
<h3 className='text-2xl font-semibold text-slate-900'>{activeCourse.title}</h3>
<p className='mt-2 max-w-3xl text-sm leading-7 text-slate-600'>
{activeCourse.description || activeCourse.short_description}
</p>
</div>
</div>
{!activeEnrollment && canEnroll ? (
<BaseButton
color='info'
label={pendingCourseId === activeCourse.id ? 'Enrolling…' : 'Enroll in this course'}
onClick={() => handleEnroll(activeCourse.id)}
disabled={pendingCourseId === activeCourse.id}
/>
) : null}
</div>
<div className='mt-5 space-y-2'>
<div className='flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500'>
<span>Course progress</span>
<span>{activeCourse.progressPercent}%</span>
</div>
<ProgressBar value={activeCourse.progressPercent} />
</div>
</div>
<div className='grid gap-6 xl:grid-cols-[0.78fr_1.22fr]'>
<div className='space-y-3'>
<div className='flex items-center justify-between'>
<h4 className='text-lg font-semibold text-slate-900'>Lesson path</h4>
<span className='text-sm text-slate-500'>
{activeCourse.completedLessons}/{activeCourse.totalLessons} completed
</span>
</div>
{activeCourse.lessons.length ? (
<div className='space-y-2'>
{activeCourse.lessons.map((lesson, index) => {
const completion = completionLookup[lesson.id];
const isSelected = activeLesson?.id === lesson.id;
const isCompleted = Boolean(completion?.is_completed);
return (
<button
type='button'
key={lesson.id}
onClick={() => 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'
}`}
>
<div className='flex items-start gap-3'>
<div className={`mt-0.5 flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
isCompleted
? 'bg-emerald-500 text-white'
: 'bg-slate-100 text-slate-600'
}`}>
{isCompleted ? '✓' : index + 1}
</div>
<div className='min-w-0 flex-1'>
<div className='flex items-start justify-between gap-3'>
<div>
<p className='font-semibold text-slate-900'>{lesson.title}</p>
<p className='mt-1 text-sm text-slate-500'>
{lesson.estimated_minutes || 5} min read
</p>
</div>
{lesson.is_preview ? (
<span className='rounded-full bg-amber-50 px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-700'>
Preview
</span>
) : null}
</div>
</div>
</div>
</button>
);
})}
</div>
) : (
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
This course is published, but there are no published lessons attached yet.
</div>
)}
</div>
<div className='rounded-3xl border border-slate-200 bg-white p-6 shadow-sm'>
{activeLesson ? (
<div className='space-y-5'>
<div className='flex flex-wrap items-start justify-between gap-4'>
<div>
<p className='text-xs font-semibold uppercase tracking-[0.2em] text-slate-500'>
Lesson reader
</p>
<h4 className='mt-2 text-2xl font-semibold text-slate-900'>{activeLesson.title}</h4>
<p className='mt-2 text-sm text-slate-500'>
Estimated time: {activeLesson.estimated_minutes || 5} minutes
</p>
</div>
{activeEnrollment ? (
<BaseButton
color={completionLookup[activeLesson.id]?.is_completed ? 'success' : 'info'}
label={
pendingLessonId === activeLesson.id
? 'Saving…'
: completionLookup[activeLesson.id]?.is_completed
? 'Mark incomplete'
: 'Mark complete'
}
onClick={() => handleToggleLesson(activeLesson.id)}
disabled={pendingLessonId === activeLesson.id}
/>
) : null}
</div>
{!activeEnrollment ? (
<div className='rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-5 text-sm leading-6 text-slate-500'>
Enroll in this course to unlock completion tracking and persist your progress.
</div>
) : null}
<div className='rounded-2xl bg-slate-50 p-5'>
<div className='whitespace-pre-wrap text-sm leading-7 text-slate-700'>
{activeLesson.content_markdown || 'Lesson content has not been added yet.'}
</div>
</div>
</div>
) : (
<div className='flex h-full min-h-[320px] flex-col items-center justify-center rounded-2xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center'>
<div className='mb-4 rounded-full bg-slate-100 p-4 text-slate-600'>
<BaseIcon path={mdiNotebookOutline} size={28} />
</div>
<h4 className='text-lg font-semibold text-slate-900'>Choose a lesson to begin</h4>
<p className='mt-2 max-w-md text-sm leading-6 text-slate-500'>
Select a lesson from the path to open the reader and record completion.
</p>
</div>
)}
</div>
</div>
</div>
) : (
<div className='flex min-h-[420px] flex-col items-center justify-center rounded-3xl border border-dashed border-slate-200 bg-slate-50 p-10 text-center'>
<div className='mb-4 rounded-full bg-white p-4 shadow-sm'>
<BaseIcon path={mdiChartTimelineVariant} size={28} />
</div>
<h3 className='text-2xl font-semibold text-slate-900'>Your learning space is ready</h3>
<p className='mt-3 max-w-lg text-sm leading-7 text-slate-500'>
Publish the first course from the admin area, then come back here to enroll and track lesson completion.
</p>
<div className='mt-5 flex flex-wrap justify-center gap-3'>
<BaseButton href='/courses/courses-list' color='info' label='Go to courses' />
<BaseButton href='/dashboard' color='info' outline label='Open dashboard' />
</div>
</div>
)}
</CardBox>
<CardBox className='border border-slate-200 shadow-sm'>
<div className='grid gap-4 md:grid-cols-3'>
<div className='rounded-2xl border border-slate-200 p-5'>
<p className='text-sm font-semibold text-slate-900'>Create</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Instructors continue using the existing course and lesson CRUD pages.</p>
</div>
<div className='rounded-2xl border border-slate-200 p-5'>
<p className='text-sm font-semibold text-slate-900'>Enroll</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Learners join a published course with one click from the catalog.</p>
</div>
<div className='rounded-2xl border border-slate-200 p-5'>
<p className='text-sm font-semibold text-slate-900'>Track progress</p>
<p className='mt-2 text-sm leading-6 text-slate-500'>Lesson completion updates enrollment progress and completion status automatically.</p>
</div>
</div>
</CardBox>
</div>
</div>
</SectionMain>
</>
);
};
LearningHub.getLayout = function getLayout(page: ReactElement) {
return <LayoutAuthenticated permission='READ_ENROLLMENTS'>{page}</LayoutAuthenticated>;
};
export default LearningHub;