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
+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