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',
+11 -7
View File
@@ -71,6 +71,16 @@ const getActivePlan = (org: AccountOrg) => {
return org.plans.find((p) => p.isActive);
};
export function isOrgOnFreePlan(org: AccountOrg | null | undefined) {
if (!org?.id || PUBLIC_IS_SELFHOSTED === 'true') {
return false;
}
const plan = getActivePlan(org);
return !plan || plan.planName === PLAN.BASIC;
}
export const currentOrgPlan = derived(currentOrg, ($currentOrg) => getActivePlan($currentOrg));
export const currentOrgPath = derived(currentOrg, ($currentOrg) =>
@@ -126,13 +136,7 @@ export function getOrgPublicUrl(org: OrgPublicOrigin, pathname = '/'): string {
export const currentOrgDomain = derived(currentOrg, ($currentOrg) => getOrgPublicOrigin($currentOrg));
export const isFreePlan = derived(currentOrg, ($currentOrg) => {
if (!$currentOrg.id || PUBLIC_IS_SELFHOSTED === 'true') return false;
const plan = getActivePlan($currentOrg);
return !plan || plan.planName === PLAN.BASIC;
});
export const isFreePlan = derived(currentOrg, ($currentOrg) => isOrgOnFreePlan($currentOrg));
export const isEnterprisePlan = derived(currentOrg, ($currentOrg) => {
if (PUBLIC_IS_SELFHOSTED === 'true') return true;
@@ -471,9 +471,22 @@
"any_course": "Ethvert kursus, du gennemfører, vil blive vist her",
"find_courses": "Find kurser"
},
"certificates": {
"heading": "Certifikater",
"description": "Se og download certifikater, du har opnået ved at gennemføre kurser.",
"search": "Søg certifikater",
"earned_on": "Opnået den",
"view_certificate": "Se certifikat",
"empty_title": "Ingen certifikater endnu",
"empty_description": "Gennemfør et kursus for at opnå dit første certifikat. Dine opnåede certifikater vises her.",
"no_results_title": "Ingen certifikater fundet",
"no_results_description": "Prøv et andet søgeord.",
"go_to_my_learning": "Gå til Min Læring"
},
"lms_navigation": {
"home": "Hjem",
"my_learning": "Min Læring",
"certificates": "Certifikater",
"exercise": "Motion",
"community": "Fællesskab",
"explore": "Udforsk",
@@ -471,9 +471,22 @@
"any_course": "Alle von Ihnen abgeschlossenen Kurse werden hier angezeigt",
"find_courses": "Kurse finden"
},
"certificates": {
"heading": "Zertifikate",
"description": "Sieh dir Zertifikate an und lade sie herunter, die du durch den Abschluss von Kursen erworben hast.",
"search": "Zertifikate suchen",
"earned_on": "Erworben am",
"view_certificate": "Zertifikat anzeigen",
"empty_title": "Noch keine Zertifikate",
"empty_description": "Schließe einen Kurs ab, um dein erstes Zertifikat zu verdienen. Deine erworbenen Zertifikate werden hier angezeigt.",
"no_results_title": "Keine Zertifikate gefunden",
"no_results_description": "Versuche einen anderen Suchbegriff.",
"go_to_my_learning": "Zu Mein Lernen"
},
"lms_navigation": {
"home": "Zuhause",
"my_learning": "Mein Lernen",
"certificates": "Zertifikate",
"exercise": "Übung",
"community": "Gemeinschaft",
"explore": "Entdecken",
@@ -4059,6 +4059,7 @@
"lms_navigation": {
"home": "Home",
"my_learning": "My Learning",
"certificates": "Certificates",
"programs": "Programs",
"exercise": "Exercise",
"community": "Community",
@@ -4077,6 +4078,18 @@
"any_course": "Any course that you complete will be displayed here",
"find_courses": "Find courses"
},
"certificates": {
"heading": "Certificates",
"description": "View and download certificates you've earned by completing courses.",
"search": "Search certificates",
"earned_on": "Earned on",
"view_certificate": "View Certificate",
"empty_title": "No certificates yet",
"empty_description": "Complete a course to earn your first certificate. Your earned certificates will appear here.",
"no_results_title": "No certificates found",
"no_results_description": "Try a different search term.",
"go_to_my_learning": "Go to My Learning"
},
"exercises": {
"heading": "Exercises",
"not_submitted": "Not Submitted",
@@ -471,9 +471,22 @@
"any_course": "Cualquier curso que completes se mostrará aquí.",
"find_courses": "encontrar cursos"
},
"certificates": {
"heading": "Certificados",
"description": "Ve y descarga los certificados que has obtenido al completar cursos.",
"search": "Buscar certificados",
"earned_on": "Obtenido el",
"view_certificate": "Ver certificado",
"empty_title": "Aún no hay certificados",
"empty_description": "Completa un curso para obtener tu primer certificado. Tus certificados obtenidos aparecerán aquí.",
"no_results_title": "No se encontraron certificados",
"no_results_description": "Prueba con otro término de búsqueda.",
"go_to_my_learning": "Ir a Mi aprendizaje"
},
"lms_navigation": {
"home": "Inicio",
"my_learning": "Mi aprendizaje",
"certificates": "Certificados",
"exercise": "Ejercicio",
"community": "Comunidad",
"explore": "Explorar",
@@ -471,9 +471,22 @@
"any_course": "Tout cours que vous terminez sera affiché ici",
"find_courses": "Trouver des cours"
},
"certificates": {
"heading": "Certificats",
"description": "Consultez et téléchargez les certificats que vous avez obtenus en terminant des cours.",
"search": "Rechercher des certificats",
"earned_on": "Obtenu le",
"view_certificate": "Voir le certificat",
"empty_title": "Aucun certificat pour le moment",
"empty_description": "Terminez un cours pour obtenir votre premier certificat. Vos certificats obtenus apparaîtront ici.",
"no_results_title": "Aucun certificat trouvé",
"no_results_description": "Essayez un autre terme de recherche.",
"go_to_my_learning": "Aller à Mon apprentissage"
},
"lms_navigation": {
"home": "Accueil",
"my_learning": "Mon apprentissage",
"certificates": "Certificats",
"exercise": "Exercice",
"community": "Communauté",
"explore": "Explorer",
@@ -471,9 +471,22 @@
"any_course": "आपके द्वारा पूरा किया गया कोई भी पाठ्यक्रम यहां प्रदर्शित किया जाएगा",
"find_courses": "पाठ्यक्रम खोजें"
},
"certificates": {
"heading": "प्रमाणपत्र",
"description": "पाठ्यक्रम पूरे करने पर आपको मिले प्रमाणपत्र देखें और डाउनलोड करें।",
"search": "प्रमाणपत्र खोजें",
"earned_on": "प्राप्त तिथि",
"view_certificate": "प्रमाणपत्र देखें",
"empty_title": "अभी तक कोई प्रमाणपत्र नहीं",
"empty_description": "अपना पहला प्रमाणपत्र अर्जित करने के लिए एक पाठ्यक्रम पूरा करें। आपके अर्जित प्रमाणपत्र यहाँ दिखाई देंगे।",
"no_results_title": "कोई प्रमाणपत्र नहीं मिला",
"no_results_description": "कोई अलग खोज शब्द आज़माएँ।",
"go_to_my_learning": "मेरी सीख पर जाएँ"
},
"lms_navigation": {
"home": "घर",
"my_learning": "मेरी सीख",
"certificates": "प्रमाणपत्र",
"exercise": "व्यायाम",
"community": "समुदाय",
"explore": "अन्वेषण करें",
@@ -471,9 +471,22 @@
"any_course": "Tutaj wyświetli się każdy ukończony kurs",
"find_courses": "Znajdź kursy"
},
"certificates": {
"heading": "Certyfikaty",
"description": "Przeglądaj i pobieraj certyfikaty uzyskane po ukończeniu kursów.",
"search": "Szukaj certyfikatów",
"earned_on": "Uzyskano",
"view_certificate": "Zobacz certyfikat",
"empty_title": "Brak certyfikatów",
"empty_description": "Ukończ kurs, aby zdobyć swój pierwszy certyfikat. Uzyskane certyfikaty pojawią się tutaj.",
"no_results_title": "Nie znaleziono certyfikatów",
"no_results_description": "Spróbuj innego wyszukiwania.",
"go_to_my_learning": "Przejdź do Moja nauka"
},
"lms_navigation": {
"home": "Dom",
"my_learning": "Moja nauka",
"certificates": "Certyfikaty",
"exercise": "Ćwicz",
"community": "Społeczność",
"explore": "Eksploruj",
@@ -471,9 +471,22 @@
"any_course": "Qualquer curso que você concluir será exibido aqui",
"find_courses": "Encontre cursos"
},
"certificates": {
"heading": "Certificados",
"description": "Veja e baixe os certificados que você obteve ao concluir cursos.",
"search": "Pesquisar certificados",
"earned_on": "Obtido em",
"view_certificate": "Ver certificado",
"empty_title": "Nenhum certificado ainda",
"empty_description": "Conclua um curso para obter seu primeiro certificado. Seus certificados obtidos aparecerão aqui.",
"no_results_title": "Nenhum certificado encontrado",
"no_results_description": "Tente outro termo de pesquisa.",
"go_to_my_learning": "Ir para Meu aprendizado"
},
"lms_navigation": {
"home": "Página inicial",
"my_learning": "Meu aprendizado",
"certificates": "Certificados",
"exercise": "Exercício",
"community": "Comunidade",
"explore": "Explorar",
@@ -471,9 +471,22 @@
"any_course": "Здесь будут отображаться все пройденные вами курсы.",
"find_courses": "Найти курсы"
},
"certificates": {
"heading": "Сертификаты",
"description": "Просматривайте и скачивайте сертификаты, полученные за завершение курсов.",
"search": "Поиск сертификатов",
"earned_on": "Получен",
"view_certificate": "Посмотреть сертификат",
"empty_title": "Пока нет сертификатов",
"empty_description": "Завершите курс, чтобы получить свой первый сертификат. Полученные сертификаты появятся здесь.",
"no_results_title": "Сертификаты не найдены",
"no_results_description": "Попробуйте другой поисковый запрос.",
"go_to_my_learning": "Перейти к Моему обучению"
},
"lms_navigation": {
"home": "Главная",
"my_learning": "Мое обучение",
"certificates": "Сертификаты",
"exercise": "Упражнение",
"community": "Сообщество",
"explore": "Исследуйте",
@@ -471,9 +471,22 @@
"any_course": "Bất kỳ khóa học nào bạn hoàn thành sẽ được hiển thị ở đây",
"find_courses": "Tìm khóa học"
},
"certificates": {
"heading": "Chứng chỉ",
"description": "Xem và tải xuống các chứng chỉ bạn đã đạt được khi hoàn thành khóa học.",
"search": "Tìm kiếm chứng chỉ",
"earned_on": "Đạt được vào",
"view_certificate": "Xem chứng chỉ",
"empty_title": "Chưa có chứng chỉ nào",
"empty_description": "Hoàn thành một khóa học để nhận chứng chỉ đầu tiên của bạn. Các chứng chỉ đã đạt được sẽ hiển thị tại đây.",
"no_results_title": "Không tìm thấy chứng chỉ",
"no_results_description": "Hãy thử một từ khóa tìm kiếm khác.",
"go_to_my_learning": "Đi tới Học tập của tôi"
},
"lms_navigation": {
"home": "Trang chủ",
"my_learning": "Học tập của tôi",
"certificates": "Chứng chỉ",
"exercise": "tập thể dục",
"community": "cộng đồng",
"explore": "Khám phá",
@@ -0,0 +1,23 @@
<script lang="ts">
import { CertificatesPage } from '$features/lms/pages';
import { t } from '$lib/utils/functions/translations';
import * as Page from '@cio/ui/base/page';
</script>
<svelte:head>
<title>Certificates - ClassroomIO</title>
</svelte:head>
<Page.Root class="w-full">
<Page.Header>
<Page.HeaderContent>
<Page.Title>{$t('certificates.heading')}</Page.Title>
<Page.Subtitle>{$t('certificates.description')}</Page.Subtitle>
</Page.HeaderContent>
</Page.Header>
<Page.Body>
{#snippet child()}
<CertificatesPage />
{/snippet}
</Page.Body>
</Page.Root>