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:
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user