feat(lms): add student certificates page and sidebar nav (#696)

* feat(lms): add student certificates page and sidebar nav

Add a Certificates item to the student LMS sidebar that lists courses
where the student has earned a certificate. Each course card includes a
View Certificate action linking to the existing per-course certificate
page.

* refactor: reuse isOrgOnFreePlan helper from org store

* chore(i18n): add certificates translations for all locales

---------
This commit is contained in:
Rotimi Best
2026-06-29 11:49:27 +01:00
committed by GitHub
parent d242215277
commit 773fc873c2
16 changed files with 265 additions and 10 deletions
@@ -36,12 +36,13 @@
isOnLandingPage?: boolean;
isLMS?: boolean;
isExplore?: boolean;
isCertificateView?: boolean;
href?: string;
actions?: Snippet;
onExploreClick?: () => void;
}
let { course, isOnLandingPage, isLMS, isExplore, href, actions, onExploreClick }: Props = $props();
let { course, isOnLandingPage, isLMS, isExplore, isCertificateView, href, actions, onExploreClick }: Props = $props();
let {
bannerImage,
@@ -150,6 +151,10 @@
return href;
}
if (isCertificateView) {
return `/courses/${id}/certificates`;
}
if (isOnLandingPage || isExplore) {
if (!slug) {
return undefined;
@@ -198,6 +203,9 @@
? getStudentCourseComplianceDate(course as UserEnrolledCourses[number])
: null
);
const certificateEarnedAt = $derived(
isCertificateView && 'certificateEarnedAt' in course ? course.certificateEarnedAt : null
);
function formatDate(value: string | null | undefined) {
if (!value) {
@@ -275,7 +283,13 @@
{/if}
</span>
{:else if isLMS}
{#if !isExplore}
{#if isCertificateView}
{#if certificateEarnedAt}
<p class="ui:text-muted-foreground text-xs">
{$t('certificates.earned_on')}: {formatDate(certificateEarnedAt)}
</p>
{/if}
{:else if !isExplore}
<div class="flex w-3/4 items-center gap-2">
<Progress value={progressRate} />
<p class="ui:text-muted-foreground text-xs">{progressRate}%</p>
@@ -305,7 +319,11 @@
{#if isLMS}
<Button variant="outline" onclick={isExplore && onExploreClick ? onExploreClick : undefined}>
{isExplore ? $t('courses.course_card.learn_more') : $t('courses.course_card.continue_course')}
{isCertificateView
? $t('certificates.view_certificate')
: isExplore
? $t('courses.course_card.learn_more')
: $t('courses.course_card.continue_course')}
<ArrowRightIcon class="custom" />
</Button>
@@ -0,0 +1,70 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { Empty } from '@cio/ui/custom/empty';
import { Search } from '@cio/ui/custom/search';
import { CourseCardList, CourseCardLoader } from '$features/course/components';
import { coursesApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
import type { UserEnrolledCourses } from '$features/course/types';
let searchValue = $state('');
const earnedCertificates = $derived.by(() => {
const courses = coursesApi.enrolledCourses.filter((course) => course.certificateEarnedAt != null);
return [...courses].sort((leftCourse, rightCourse) => {
const leftDate = leftCourse.certificateEarnedAt ? new Date(leftCourse.certificateEarnedAt).getTime() : 0;
const rightDate = rightCourse.certificateEarnedAt ? new Date(rightCourse.certificateEarnedAt).getTime() : 0;
return rightDate - leftDate;
});
});
const filteredCertificates = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
if (!query) {
return earnedCertificates;
}
return earnedCertificates.filter((course) => {
const title = course.title?.toLowerCase() ?? '';
const description = course.description?.toLowerCase() ?? '';
return title.includes(query) || description.includes(query);
});
});
$effect(() => {
if (!$profile.id || !$currentOrg.id) return;
coursesApi.getEnrolledCourses();
});
</script>
<div class="space-y-4">
<Search bind:value={searchValue} placeholder={$t('certificates.search')} />
{#if coursesApi.isLoading}
<section class="cards-container">
<CourseCardLoader />
<CourseCardLoader />
<CourseCardLoader />
</section>
{:else if filteredCertificates.length === 0}
<Empty
title={searchValue.trim() ? $t('certificates.no_results_title') : $t('certificates.empty_title')}
description={searchValue.trim()
? $t('certificates.no_results_description')
: $t('certificates.empty_description')}
>
{#if !searchValue.trim()}
<Button href="/lms/mylearning">{$t('certificates.go_to_my_learning')}</Button>
{/if}
</Empty>
{:else}
<CourseCardList courses={filteredCertificates as UserEnrolledCourses} isLMS={true} isCertificateView={true} />
{/if}
</div>
@@ -1,4 +1,5 @@
export { default as DashboardPage } from './dashboard.svelte';
export { default as ExplorePage } from './explore.svelte';
export { default as MyLearningPage } from './mylearning.svelte';
export { default as CertificatesPage } from './certificates.svelte';
export { default as ExercisesPage } from './exercises.svelte';
@@ -1,4 +1,5 @@
import {
CertificateIcon,
CommunityIcon,
CourseIcon,
ExerciseIcon,
@@ -11,6 +12,7 @@ import {
import type { AccountOrg } from '$features/app/types';
import type { Component } from 'svelte';
import { isActive } from '$lib/utils/functions/app';
import { isOrgOnFreePlan } from '$lib/utils/store/org';
export interface NavItem {
title: string;
@@ -57,6 +59,13 @@ export const baseNavConfig: NavItemConfig[] = [
icon: CourseIcon,
matchPattern: '^/lms/mylearning(/.*)?$'
},
{
titleKey: 'lms_navigation.certificates',
path: '/certificates',
icon: CertificateIcon,
matchPattern: '^/lms/certificates(/.*)?$',
show: (currentOrg) => !isOrgOnFreePlan(currentOrg)
},
{
titleKey: 'lms_navigation.explore',
path: '/explore',