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
@@ -3,7 +3,10 @@
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { DEFAULT_COURSE_BANNER_IMAGE } from '@cio/ui';
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import HTMLRender from '$features/ui/html-render.svelte';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import XIcon from '@lucide/svelte/icons/x';
import { t } from '$lib/utils/functions/translations';
import { calcCourseDiscount, isCourseFree } from '$lib/utils/functions/course';
import getCurrencyFormatter from '$lib/utils/functions/getCurrencyFormatter';
@@ -44,12 +47,22 @@
</script>
<Dialog.Root bind:open>
<Dialog.Content class="overflow-hidden p-0 sm:max-w-2xl">
<div class="aspect-video w-full overflow-hidden">
<img src={course.logo || DEFAULT_COURSE_BANNER_IMAGE} alt={course.title} class="h-full w-full object-cover" />
<Dialog.Content class="ui:p-0 overflow-hidden sm:max-w-2xl" showCloseButton={false}>
<div class="relative overflow-hidden rounded-md">
<img
src={course.logo || DEFAULT_COURSE_BANNER_IMAGE}
alt={course.title}
class="aspect-video w-full object-cover"
/>
<Dialog.Close
class="ui:absolute ui:top-3 ui:right-3 ui:inline-flex ui:size-8 ui:items-center ui:justify-center ui:rounded-md ui:bg-secondary ui:text-secondary-foreground ui:hover:bg-secondary/80 ui:transition-colors ui:cursor-pointer"
>
<XIcon class="ui:size-4" />
<span class="ui:sr-only">Close</span>
</Dialog.Close>
</div>
<div class="max-h-80 overflow-y-auto px-6 py-4">
<div class="max-h-72 overflow-y-auto px-6 py-4">
<h2 class="text-xl font-semibold tracking-tight">{course.title}</h2>
{#if course.description}
@@ -70,12 +83,14 @@
{#if requirements}
<div class="mt-4">
<h3 class="text-sm font-semibold">{$t('course.navItem.landing_page.requirement')}</h3>
<p class="ui:text-muted-foreground mt-1 text-sm leading-relaxed">{requirements}</p>
<HTMLRender className="text-sm mt-1" disableMaxWidth={true}>
<SafeHtmlContent content={requirements} />
</HTMLRender>
</div>
{/if}
</div>
<div class="ui:border-t ui:bg-card sticky bottom-0 flex items-center justify-between px-6 py-4">
<div class="ui:border-t ui:bg-card sticky bottom-0 flex items-center justify-between px-6 py-4" data-sticky="true">
<div>
{#if allowNewStudent}
<p class="text-lg font-bold">
@@ -17,7 +17,8 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { coursesApi } from '$features/course/api';
import { CourseCard } from '$features/course/components';
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import { CourseListRow } from '$features/course/components';
import CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
import type { RecommendedCourses } from '$features/course/types';
@@ -63,7 +64,7 @@
if (!$profile.id || !$currentOrg.id) return;
coursesApi.getEnrolledCourses();
coursesApi.getRecommendedCourses({ limit: 6 });
coursesApi.getRecommendedCourses({ limit: 3 });
});
$effect(() => {
@@ -103,7 +104,7 @@
return getStudentCourseProgressPercent(rightCourse) - getStudentCourseProgressPercent(leftCourse);
})
.slice(0, 3);
.slice(0, 2);
}
async function loadLoginStreak() {
@@ -261,9 +262,14 @@
<div class="grid items-stretch gap-6 lg:grid-cols-[minmax(0,1fr)_minmax(280px,0.72fr)]">
<section class="flex h-full flex-col space-y-4">
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
{$t('dashboard.currently_learning')}
</h2>
<div class="flex items-center justify-between">
<h2 class="ui:text-muted-foreground text-sm font-semibold tracking-[0.14em] uppercase">
{$t('dashboard.currently_learning')}
</h2>
<Button variant="outline" size="sm" onclick={() => goto('/lms/mylearning')}>
{$t('dashboard.view_more')}
</Button>
</div>
<div class="flex flex-1 flex-col space-y-3">
{#if coursesApi.isLoading}
@@ -418,19 +424,28 @@
<Spinner class="size-6" />
</div>
{:else}
<div class="grid w-full grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<ResourceListRow.Group>
{#each coursesApi.recommendedCourses as course (course.id)}
<CourseCard
{course}
<CourseListRow
id={course.id}
slug={course.slug ?? ''}
title={course.title}
logo={course.logo}
type={course.type}
description={course.description ?? ''}
isPublished={course.isPublished ?? false}
lessonCount={course.lessonCount}
exerciseCount={course.exerciseCount}
isExplore={true}
isLMS={true}
hiddenColumns={['published', 'tags', 'students']}
onExploreClick={() => {
selectedCourse = course;
previewOpen = true;
}}
/>
{/each}
</div>
</ResourceListRow.Group>
{/if}
</section>
{/if}
@@ -10,11 +10,15 @@
import type { RecommendedCourses } from '$features/course/types';
import { CourseSortBy, DEFAULT_COURSE_SORT, parseCourseSortValue } from '$features/course/utils/constants';
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
import * as Pagination from '@cio/ui/base/pagination';
const EXPLORE_LIMIT = 12;
let searchValue = $state('');
let sortKey = $state<CourseSortBy>(DEFAULT_COURSE_SORT);
let selectedCourse = $state<RecommendedCourses[number] | null>(null);
let previewOpen = $state(false);
let currentPage = $state(1);
$effect(() => {
if (!browser) return;
@@ -44,6 +48,19 @@
return coursesFiltered;
});
const pagination = $derived(coursesApi.recommendedCoursesPagination);
function fetchPage(page: number) {
currentPage = page;
coursesApi.getRecommendedCourses({ limit: EXPLORE_LIMIT, page });
}
$effect(() => {
if (searchValue) {
currentPage = 1;
}
});
onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
@@ -57,7 +74,7 @@
$effect(() => {
if (!$profile.id || !$currentOrg.id) return;
coursesApi.getRecommendedCourses();
coursesApi.getRecommendedCourses({ limit: EXPLORE_LIMIT, page: currentPage });
});
</script>
@@ -76,6 +93,40 @@
}}
/>
{#if pagination && pagination.totalPages > 1}
<Pagination.Root
count={pagination.total}
perPage={pagination.limit}
page={currentPage}
onPageChange={fetchPage}
class="mt-6"
>
{#snippet children({ pages, currentPage: activePage })}
<Pagination.Content>
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as pageItem (pageItem.key)}
{#if pageItem.type === 'ellipsis'}
<Pagination.Item>
<Pagination.Ellipsis />
</Pagination.Item>
{:else}
<Pagination.Item>
<Pagination.Link page={pageItem} isActive={activePage === pageItem.value}>
{pageItem.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
{/snippet}
</Pagination.Root>
{/if}
{#if selectedCourse}
<CoursePreviewModal course={selectedCourse} bind:open={previewOpen} />
{/if}