feat: show all courses available to students (#356)

* working on the fetch explore course

* working on the showing the right course

* finished the explore courses page

* finished the explore courses page

* updated the getcourseurl

* updated the migration file

* text and spacing improvements

* update translations

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Em_Dev
2024-07-09 21:17:34 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 13b493f22b
commit 98575e4dd9
15 changed files with 274 additions and 35 deletions
@@ -28,3 +28,20 @@ export async function fetchCourses(profileId, orgId) {
return { allCourses };
}
export async function fetchExploreCourses(profileId, orgId) {
if (!orgId || !profileId) return;
const { data: allCourses } = await supabase.rpc('get_explore_courses', {
org_id_arg: orgId,
profile_id_arg: profileId
});
if (!Array.isArray(allCourses)) {
return {
allCourses: []
};
}
return { allCourses };
}
@@ -32,6 +32,7 @@
export let currency = 'NGN';
export let isOnLandingPage = false;
export let isLMS = false;
export let isExplore = false;
export let progressRate = 45;
export let type: COURSE_TYPE;
@@ -60,7 +61,7 @@
}
function getCourseUrl() {
return isOnLandingPage
return isOnLandingPage || isExplore
? `/course/${slug}`
: `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
}
@@ -159,11 +160,11 @@
</div>
<div
class="px-4 border border-gray dark:border-neutral-600 border-b-0 border-l-0 border-r-0 flex justify-between {isLMS &&
class="px-4 py-2 border border-gray dark:border-neutral-600 border-b-0 border-l-0 border-r-0 flex justify-between {isLMS &&
'items-center'}"
>
<div>
<p class="text-xs pt-2 {!isLMS && 'pl-2'} dark:text-white">
<p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons}
{$t('courses.course_card.lessons_number')}
</p>
@@ -175,15 +176,17 @@
: formatter.format(cost)}</span
>
{:else if isLMS}
<div class="flex items-center gap-2">
<div class=" relative bg-[#EAEAEA] w-[50px] h-1">
<div
style="width:{progressRate}%"
class={`absolute top-0 left-0 bg-primary-700 h-full`}
/>
{#if !isExplore}
<div class="flex items-center gap-2">
<div class=" relative bg-[#EAEAEA] w-[50px] h-1">
<div
style="width:{progressRate}%"
class={`absolute top-0 left-0 bg-primary-700 h-full`}
/>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
</div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p>
</div>
{/if}
{:else}
<Tag type={isPublished ? 'green' : 'cool-gray'}>
{#if isPublished}
@@ -198,13 +201,15 @@
{#if isLMS}
<PrimaryButton
label={$t('courses.course_card.continue_course')}
label={isExplore
? $t('courses.course_card.learn_more')
: $t('courses.course_card.continue_course')}
variant={VARIANTS.OUTLINED}
className="rounded-none"
/>
{:else if !isOnLandingPage}
<div class="flex flex-col justify-between">
<p class="text-xs pt-2 pl-2 dark:text-white">
<p class="text-xs pl-2 dark:text-white">
{totalStudents}
{$t('courses.course_card.students')}
</p>
@@ -19,6 +19,7 @@
export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title');
export let emptyDescription = $t('courses.course_card.empty_description');
export let isExplore = false;
function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) {
@@ -83,6 +84,7 @@
{#key courseData.id}
<Card
id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title}
description={courseData.description}
@@ -93,6 +95,7 @@
totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students}
isLMS={$globalStore.isOrgSite}
{isExplore}
progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)}
/>
{/key}
@@ -2,6 +2,7 @@
import { page } from '$app/stores';
import HelpIcon from 'carbon-icons-svelte/lib/Help.svelte';
import LicenseDraft from 'carbon-icons-svelte/lib/LicenseDraft.svelte';
import Explore from 'carbon-icons-svelte/lib/Explore.svelte';
import Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import HomeIcon from '$lib/components/Icons/HomeIcon.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
@@ -59,6 +60,11 @@
show() {
return $currentOrg?.customization?.dashboard?.community;
}
},
{
name: $t('lms_navigation.explore'),
icon: Explore,
link: '/lms/explore'
}
].filter((link) => (link.show ? link.show() : true));
@@ -185,7 +185,8 @@
"exercise": "Übung",
"community": "Gemeinschaft",
"help": "Helfen",
"settings": "Einstellungen"
"settings": "Einstellungen",
"explore": "Erkunden"
},
"upgrade": {
"certificate": "Aktualisieren Sie Ihren Plan, um Zertifikate zu generieren",
@@ -843,7 +844,9 @@
"share": "Aktie",
"invite": "Einladen",
"delete": "Löschen"
}
},
"get_course": "Holen Sie sich Kurs",
"learn_more": "Erfahren Sie mehr"
},
"course_filter": {
"date_created": "Datum erstellt",
@@ -1369,5 +1372,10 @@
"min_char": "Die Organisation muss 5 oder mehr Zeichen enthalten",
"hyphen_rule": "Der Name der Organisation darf nicht mit einem Bindestrich beginnen oder enden"
}
},
"explore": {
"empty_heading": "Kein Kurs verfügbar",
"empty_description": "Derzeit sind keine veröffentlichten Kurse verfügbar",
"heading": "Entdecken Sie unsere Kurse"
}
}
}
@@ -428,6 +428,7 @@
"published": "Published",
"unpublished": "Unpublished",
"continue_course": "Continue Course",
"learn_more": "Learn more",
"list_view": {
"title": "Title",
"description": "Description",
@@ -1101,6 +1102,7 @@
"my_learning": "My Learning",
"exercise": "Exercise",
"community": "Community",
"explore": "Explore",
"help": "Help",
"settings": "Settings"
},
@@ -1125,6 +1127,12 @@
"lesson": "Lesson"
},
"explore": {
"heading": "Explore our courses",
"empty_heading": "No course available",
"empty_description": "There are currently no published course available"
},
"snackbar": {
"success": "Success",
"success_update": "Updated successfully",
@@ -185,7 +185,8 @@
"exercise": "Ejercicio",
"community": "Comunidad",
"help": "Ayuda",
"settings": "Ajustes"
"settings": "Ajustes",
"explore": "Explorar"
},
"upgrade": {
"certificate": "Actualice su plan para generar certificados",
@@ -843,7 +844,9 @@
"share": "Compartir",
"invite": "Invitar",
"delete": "Borrar"
}
},
"get_course": "Obtener curso",
"learn_more": "Aprende más"
},
"course_filter": {
"date_created": "fecha de creacion",
@@ -1369,5 +1372,10 @@
"min_char": "La organización debe contener 5 o más caracteres.",
"hyphen_rule": "El nombre de la organización no puede comenzar ni terminar con un guión."
}
},
"explore": {
"empty_heading": "No hay curso disponible",
"empty_description": "Actualmente no hay ningún curso publicado disponible",
"heading": "Explora nuestros cursos"
}
}
}
@@ -185,7 +185,8 @@
"exercise": "Exercice",
"community": "Communauté",
"help": "Aide",
"settings": "Paramètres"
"settings": "Paramètres",
"explore": "Explorer"
},
"upgrade": {
"certificate": "Mettez à niveau votre plan pour générer des certificats",
@@ -843,7 +844,9 @@
"share": "Partager",
"invite": "Inviter",
"delete": "Supprimer"
}
},
"get_course": "Obtenir un cours",
"learn_more": "Apprendre encore plus"
},
"course_filter": {
"date_created": "date créée",
@@ -1369,5 +1372,10 @@
"min_char": "L'organisation doit contenir 5 caractères ou plus",
"hyphen_rule": "Le nom de l'organisation ne peut pas commencer ou se terminer par un trait d'union"
}
},
"explore": {
"empty_heading": "Aucun cours disponible",
"empty_description": "Il n'y a actuellement aucun cours publié disponible",
"heading": "Découvrez nos cours"
}
}
}
@@ -185,7 +185,8 @@
"exercise": "व्यायाम",
"community": "समुदाय",
"help": "मदद",
"settings": "समायोजन"
"settings": "समायोजन",
"explore": "अन्वेषण करना"
},
"upgrade": {
"certificate": "प्रमाणपत्र बनाने की अपनी योजना को अपग्रेड करें",
@@ -843,7 +844,9 @@
"share": "शेयर करना",
"invite": "आमंत्रित करना",
"delete": "मिटाना"
}
},
"get_course": "पाठ्यक्रम प्राप्त करें",
"learn_more": "और अधिक जानें"
},
"course_filter": {
"date_created": "बनाया गया दिनांक",
@@ -1369,5 +1372,10 @@
"min_char": "संगठन में 5 या अधिक वर्ण होने चाहिए",
"hyphen_rule": "संगठन का नाम हाइफ़न से प्रारंभ या समाप्त नहीं हो सकता"
}
},
"explore": {
"empty_heading": "कोई पाठ्यक्रम उपलब्ध नहीं है",
"empty_description": "वर्तमान में कोई प्रकाशित पाठ्यक्रम उपलब्ध नहीं है",
"heading": "हमारे पाठ्यक्रमों का अन्वेषण करें"
}
}
}
@@ -185,7 +185,8 @@
"exercise": "Exercício",
"community": "Comunidade",
"help": "Ajuda",
"settings": "Configurações"
"settings": "Configurações",
"explore": "Explorar"
},
"upgrade": {
"certificate": "Atualize seu plano para gerar certificados",
@@ -843,7 +844,9 @@
"share": "Compartilhar",
"invite": "Convidar",
"delete": "Excluir"
}
},
"get_course": "Obter curso",
"learn_more": "Saber mais"
},
"course_filter": {
"date_created": "Data Criada",
@@ -1369,5 +1372,10 @@
"min_char": "A organização deve conter 5 ou mais caracteres",
"hyphen_rule": "O nome da organização não pode começar ou terminar com um hífen"
}
},
"explore": {
"empty_heading": "Nenhum curso disponível",
"empty_description": "Atualmente não há nenhum curso publicado disponível",
"heading": "Explore nossos cursos"
}
}
}
@@ -185,7 +185,8 @@
"exercise": "Упражнение",
"community": "Сообщество",
"help": "Помощь",
"settings": "Настройки"
"settings": "Настройки",
"explore": "Исследовать"
},
"upgrade": {
"certificate": "Обновите свой план для создания сертификатов",
@@ -843,7 +844,9 @@
"share": "Делиться",
"invite": "Приглашать",
"delete": "Удалить"
}
},
"get_course": "Получить курс",
"learn_more": "Узнать больше"
},
"course_filter": {
"date_created": "Дата создания",
@@ -1369,5 +1372,10 @@
"min_char": "Организация должна содержать 5 или более символов.",
"hyphen_rule": "Название организации не может начинаться или заканчиваться дефисом."
}
},
"explore": {
"empty_heading": "Нет доступных курсов",
"empty_description": "На данный момент нет доступных опубликованных курсов",
"heading": "Ознакомьтесь с нашими курсами"
}
}
}
@@ -185,7 +185,8 @@
"exercise": "Bài tập",
"community": "Cộng đồng",
"help": "Giúp đỡ",
"settings": "Cài đặt"
"settings": "Cài đặt",
"explore": "Khám phá"
},
"upgrade": {
"certificate": "Nâng cấp gói của bạn để tạo chứng chỉ",
@@ -843,7 +844,9 @@
"share": "Chia sẻ",
"invite": "Mời",
"delete": "Xóa bỏ"
}
},
"get_course": "Nhận khóa học",
"learn_more": "Tìm hiểu thêm"
},
"course_filter": {
"date_created": "Ngày tạo",
@@ -1369,5 +1372,10 @@
"min_char": "Tổ chức phải chứa 5 ký tự trở lên",
"hyphen_rule": "Tên tổ chức không thể bắt đầu hoặc kết thúc bằng dấu gạch nối"
}
},
"explore": {
"empty_heading": "Không có khóa học nào",
"empty_description": "Hiện tại chưa có khóa học nào được xuất bản",
"heading": "Khám phá các khóa học của chúng tôi"
}
}
}
@@ -0,0 +1,127 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Search, Dropdown } from 'carbon-components-svelte';
import { profile } from '$lib/utils/store/user';
import { fetchExploreCourses } from '$lib/components/Courses/api';
import Courses from '$lib/components/Courses/index.svelte';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { currentOrg } from '$lib/utils/store/org';
import type { Course } from '$lib/utils/types';
import { browser } from '$app/environment';
import { t } from '$lib/utils/functions/translations';
import IconButton from '$lib/components/IconButton/index.svelte';
import Grid from 'carbon-icons-svelte/lib/Grid.svelte';
import List from 'carbon-icons-svelte/lib/List.svelte';
let searchValue = '';
let selectedId: string;
let filteredExploreCourses: Course[];
let hasFetched = false;
let exploreCourseList: Course[] = [];
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched || !userId || !orgId) {
return;
}
$courseMetaDeta.isLoading = true;
const coursesResult = await fetchExploreCourses(userId, orgId);
$courseMetaDeta.isLoading = false;
if (!coursesResult) return;
exploreCourseList = coursesResult.allCourses;
hasFetched = true;
}
function filterCourses(searchValue: string, _selectedId: string, courses: Course[]) {
if (browser) {
if (!selectedId) {
selectedId = localStorage.getItem('classroomio_filter_course_key') || '0';
} else {
localStorage.setItem('classroomio_filter_course_key', _selectedId);
}
}
filteredExploreCourses = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
return false;
});
if (_selectedId === '0') {
filteredExploreCourses = filteredExploreCourses.sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
} else if (_selectedId === '1') {
filteredExploreCourses = filteredExploreCourses.sort(
(a, b) => b.total_lessons - a.total_lessons
);
}
}
const setViewPreference = (preference: 'grid' | 'list') => {
$courseMetaDeta.view = preference;
localStorage.setItem('courseView', preference);
};
onMount(() => {
const courseView = localStorage.getItem('courseView') as 'grid' | 'list' | null;
if (courseView) {
$courseMetaDeta.view = courseView;
}
});
$: filterCourses(searchValue, selectedId, exploreCourseList);
$: if (browser && $profile.id && $currentOrg.id) {
getCourses($profile.id, $currentOrg.id);
}
</script>
<section class="w-full md:max-w-6xl md:mx-auto">
<div class="py-2 md:py-10 px-2 md:px-5">
<h1 class="dark:text-white text-3xl font-bold">{$t('explore.heading')}</h1>
<div class="flex flex-row-reverse">
<div class="filter-container flex items-end justify-start">
<Search
placeholder={$t('courses.search_placeholder')}
bind:value={searchValue}
searchClass="mr-2"
class=" bg-gray-100 dark:bg-neutral-800"
/>
<Dropdown
class="h-full"
size="xl"
bind:selectedId
items={[
{ id: '0', text: $t('courses.course_filter.date_created') },
{ id: '1', text: $t('courses.course_filter.lessons') }
]}
/>
{#if $courseMetaDeta.view === 'list'}
<IconButton onClick={() => setViewPreference('grid')}>
<Grid size={24} />
</IconButton>
{:else}
<IconButton onClick={() => setViewPreference('list')}>
<List size={24} />
</IconButton>
{/if}
</div>
</div>
<Courses
bind:courses={filteredExploreCourses}
emptyTitle={$t('explore.empty_heading')}
emptyDescription={$t('explore.empty_description')}
isExplore={true}
/>
</div>
</section>
@@ -57,7 +57,7 @@
}
filteredCourses = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue)) {
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true;
}
@@ -0,0 +1,17 @@
CREATE OR REPLACE FUNCTION public.get_explore_courses(org_id_arg uuid, profile_id_arg uuid)
RETURNS TABLE(id uuid, org_id uuid, title character varying, slug character varying, description character varying, logo text, banner_image text, cost bigint, currency character varying, is_published boolean, total_lessons bigint, total_students bigint, progress_rate bigint, type "COURSE_TYPE", other_profile_id uuid)
LANGUAGE plpgsql
AS $function$
BEGIN
Return query
select course.id, organization.id AS org_id, course.title, course.slug, course.description, course.logo, course.banner_image, course.cost, course.currency, course.is_published, (select COUNT(*) from lesson
as l where l.course_id = course.id) AS total_lessons, (select COUNT(*) from groupmember as gm where gm.group_id = course.group_id AND gm.role_id = 3) as total_students, (select COUNT(*) from lesson_completion as lc join lesson as l on l.id = lc.lesson_id where l.course_id = course.id and lc.is_complete = true and lc.profile_id = profile_id_arg) AS progress_rate, course.type as type, (select groupmember.profile_id from groupmember where groupmember.group_id = "group".id and groupmember.profile_id != profile_id_arg
limit 1) as other_profile_id
from course
join "group" on "group".id = course.group_id
join organization on organization.id = "group".organization_id
where course.status = 'ACTIVE' AND course.is_published = true AND organization.id = org_id_arg AND profile_id_arg NOT IN (SELECT groupmember.profile_id FROM groupmember WHERE groupmember.group_id = course.group_id)
ORDER BY course.created_at DESC;
END;
$function$
;