feat: improve LMS course discovery and enrollment UX

- Add paginated recommended courses to LMS dashboard (top 3, list
    view)
    and explore page (12 per page); exclude courses blocking new
    students
  - Polish course preview modal: remove padding, render requirements as
    HTML, round banner image, secondary close button
  - Redirect to course start page after successful enrollment instead of
    /lms;
    keep spinner active until navigation completes
  - Auto-enable "allow new students" when a course is published
  - Move Explore after My Learning in LMS sidebar nav
  - Move Certificates after Content in course sidebar
  - Add hiddenColumns prop to CourseListRow; show "Learn more" button
    instead of actions menu in LMS explore context
This commit is contained in:
Rotimi Best
2026-06-11 14:22:18 +01:00
parent 4e24e3cb63
commit c8154409f5
15 changed files with 465 additions and 194 deletions
+58 -28
View File
@@ -1023,6 +1023,12 @@ interface GetExploreCoursesOptions {
orgId: string;
profileId: string;
limit?: number;
page?: number;
}
export interface GetExploreCoursesResult {
data: TBaseCourse[];
total: number;
}
/**
@@ -1031,25 +1037,40 @@ interface GetExploreCoursesOptions {
* @param options.orgId Organization ID
* @param options.profileId Profile ID to exclude enrolled courses
* @param options.limit Optional max number of courses to return
* @returns Array of courses with explore-level data
* @param options.page Optional page number (1-based, used with limit)
* @returns Courses with explore-level data and total count
*/
export const getExploreCourses = async ({
orgId,
profileId,
limit
}: GetExploreCoursesOptions): Promise<TBaseCourse[]> => {
limit,
page = 1
}: GetExploreCoursesOptions): Promise<GetExploreCoursesResult> => {
try {
const baseQuery = db
.select({
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
exerciseCount: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count')
})
const whereCondition = and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
isNull(schema.groupmember.id),
or(
sql`${schema.course.metadata}->>'allowNewStudent' IS NULL`,
sql`${schema.course.metadata}->>'allowNewStudent' != 'false'`
)
);
const courseSelect = {
course: schema.course,
lessonCount: sql<number>`COUNT(DISTINCT ${schema.lesson.id})`.as('total_lessons'),
exerciseCount: sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
LEFT JOIN ${schema.lesson} as el ON el.id = ex.lesson_id
WHERE ${or(eq(sql`ex.course_id`, schema.course.id), eq(sql`el.course_id`, schema.course.id))}
)`.as('exercise_count')
};
const baseSelect = db
.select(courseSelect)
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(schema.lesson, eq(schema.course.id, schema.lesson.courseId))
@@ -1057,24 +1078,33 @@ export const getExploreCourses = async ({
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId))
)
.where(
and(
eq(schema.group.organizationId, orgId),
eq(schema.course.status, 'ACTIVE'),
eq(schema.course.isPublished, true),
isNull(schema.groupmember.id)
)
)
.where(whereCondition)
.groupBy(schema.course.id, schema.group.id)
.orderBy(desc(schema.course.createdAt));
const result = limit ? await baseQuery.limit(limit) : await baseQuery;
const countSelect = db
.select({ total: count(schema.course.id) })
.from(schema.course)
.innerJoin(schema.group, eq(schema.course.groupId, schema.group.id))
.leftJoin(
schema.groupmember,
and(eq(schema.groupmember.groupId, schema.course.groupId), eq(schema.groupmember.profileId, profileId))
)
.where(whereCondition);
return result.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
exerciseCount: Number(row.exerciseCount)
}));
const [countRows, rows] = await Promise.all([
countSelect,
limit ? baseSelect.limit(limit).offset((page - 1) * limit) : baseSelect
]);
return {
data: rows.map((row) => ({
...row.course,
lessonCount: Number(row.lessonCount),
exerciseCount: Number(row.exerciseCount)
})),
total: Number(countRows[0]?.total ?? 0)
};
} catch (error) {
console.error('getExploreCourses error:', error);
throw new Error(`Failed to get explore courses: ${error instanceof Error ? error.message : 'Unknown error'}`);
+15 -4
View File
@@ -4,7 +4,8 @@ import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants';
import { ZCourseCalloutInput } from './callout';
export const ZGetRecommendedCourses = z.object({
limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional()
limit: z.string().transform(Number).pipe(z.number().min(1).max(50)).optional(),
page: z.string().transform(Number).pipe(z.number().min(1)).optional()
});
export type TGetRecommendedCourses = z.infer<typeof ZGetRecommendedCourses>;
@@ -57,7 +58,9 @@ export type TCourseDownloadParam = z.infer<typeof ZCourseDownloadParam>;
*/
export const ZCertificateSignatory = z.object({
name: z.string().max(80).default(''),
role: z.string().max(80).default('')
role: z.string().max(80).default(''),
enabled: z.boolean().default(true),
signatureUrl: z.url().optional()
});
export type TCertificateSignatory = z.infer<typeof ZCertificateSignatory>;
@@ -82,7 +85,14 @@ export type TCertificateDesign = z.infer<typeof ZCertificateDesign>;
export const ZCertificateDownloadRequest = z.object({
studentName: z.string().min(1).max(120),
studentId: z.string().min(1).max(64).optional(),
issuedAt: z.string().datetime().optional(),
issuedAt: z.preprocess((value) => {
if (typeof value !== 'string' || value.trim() === '') return undefined;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return undefined;
return parsed.toISOString();
}, z.string().datetime().optional()),
/** When true, skips the eligibility check (course owner previewing their own design). */
previewMode: z.boolean().optional()
});
@@ -355,7 +365,8 @@ const ZCourseMetadataFields = z.object({
lessonDownload: z.boolean().optional(),
allowNewStudent: z.boolean(),
sectionDisplay: z.record(z.string(), z.boolean()).optional(),
isContentGroupingEnabled: z.boolean().optional()
isContentGroupingEnabled: z.boolean().optional(),
progressionMode: z.enum(['free', 'sequential']).optional()
});
// Course metadata schema matching the database structure