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 }; 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 currency = 'NGN';
export let isOnLandingPage = false; export let isOnLandingPage = false;
export let isLMS = false; export let isLMS = false;
export let isExplore = false;
export let progressRate = 45; export let progressRate = 45;
export let type: COURSE_TYPE; export let type: COURSE_TYPE;
@@ -60,7 +61,7 @@
} }
function getCourseUrl() { function getCourseUrl() {
return isOnLandingPage return isOnLandingPage || isExplore
? `/course/${slug}` ? `/course/${slug}`
: `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`; : `/courses/${id}${isLMS ? '/lessons?next=true' : ''}`;
} }
@@ -159,11 +160,11 @@
</div> </div>
<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'}" 'items-center'}"
> >
<div> <div>
<p class="text-xs pt-2 {!isLMS && 'pl-2'} dark:text-white"> <p class="text-xs {!isLMS && 'pl-2'} dark:text-white">
{totalLessons} {totalLessons}
{$t('courses.course_card.lessons_number')} {$t('courses.course_card.lessons_number')}
</p> </p>
@@ -175,15 +176,17 @@
: formatter.format(cost)}</span : formatter.format(cost)}</span
> >
{:else if isLMS} {:else if isLMS}
<div class="flex items-center gap-2"> {#if !isExplore}
<div class=" relative bg-[#EAEAEA] w-[50px] h-1"> <div class="flex items-center gap-2">
<div <div class=" relative bg-[#EAEAEA] w-[50px] h-1">
style="width:{progressRate}%" <div
class={`absolute top-0 left-0 bg-primary-700 h-full`} 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> </div>
<p class="text-xs text-[#656565] dark:text-white">{progressRate}%</p> {/if}
</div>
{:else} {:else}
<Tag type={isPublished ? 'green' : 'cool-gray'}> <Tag type={isPublished ? 'green' : 'cool-gray'}>
{#if isPublished} {#if isPublished}
@@ -198,13 +201,15 @@
{#if isLMS} {#if isLMS}
<PrimaryButton <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} variant={VARIANTS.OUTLINED}
className="rounded-none" className="rounded-none"
/> />
{:else if !isOnLandingPage} {:else if !isOnLandingPage}
<div class="flex flex-col justify-between"> <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} {totalStudents}
{$t('courses.course_card.students')} {$t('courses.course_card.students')}
</p> </p>
@@ -19,6 +19,7 @@
export let courses: Course[] = []; export let courses: Course[] = [];
export let emptyTitle = $t('courses.course_card.empty_title'); export let emptyTitle = $t('courses.course_card.empty_title');
export let emptyDescription = $t('courses.course_card.empty_description'); export let emptyDescription = $t('courses.course_card.empty_description');
export let isExplore = false;
function calcProgressRate(progressRate?: number, totalLessons?: number): number { function calcProgressRate(progressRate?: number, totalLessons?: number): number {
if (!progressRate || !totalLessons) { if (!progressRate || !totalLessons) {
@@ -83,6 +84,7 @@
{#key courseData.id} {#key courseData.id}
<Card <Card
id={courseData.id} id={courseData.id}
slug={courseData.slug}
bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'} bannerImage={courseData.logo || '/images/classroomio-course-img-template.jpg'}
title={courseData.title} title={courseData.title}
description={courseData.description} description={courseData.description}
@@ -93,6 +95,7 @@
totalLessons={courseData.total_lessons} totalLessons={courseData.total_lessons}
totalStudents={courseData.total_students} totalStudents={courseData.total_students}
isLMS={$globalStore.isOrgSite} isLMS={$globalStore.isOrgSite}
{isExplore}
progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)} progressRate={calcProgressRate(courseData.progress_rate, courseData.total_lessons)}
/> />
{/key} {/key}
@@ -2,6 +2,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import HelpIcon from 'carbon-icons-svelte/lib/Help.svelte'; import HelpIcon from 'carbon-icons-svelte/lib/Help.svelte';
import LicenseDraft from 'carbon-icons-svelte/lib/LicenseDraft.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 Settings from 'carbon-icons-svelte/lib/Settings.svelte';
import HomeIcon from '$lib/components/Icons/HomeIcon.svelte'; import HomeIcon from '$lib/components/Icons/HomeIcon.svelte';
import CourseIcon from '$lib/components/Icons/CourseIcon.svelte'; import CourseIcon from '$lib/components/Icons/CourseIcon.svelte';
@@ -59,6 +60,11 @@
show() { show() {
return $currentOrg?.customization?.dashboard?.community; return $currentOrg?.customization?.dashboard?.community;
} }
},
{
name: $t('lms_navigation.explore'),
icon: Explore,
link: '/lms/explore'
} }
].filter((link) => (link.show ? link.show() : true)); ].filter((link) => (link.show ? link.show() : true));
@@ -185,7 +185,8 @@
"exercise": "Übung", "exercise": "Übung",
"community": "Gemeinschaft", "community": "Gemeinschaft",
"help": "Helfen", "help": "Helfen",
"settings": "Einstellungen" "settings": "Einstellungen",
"explore": "Erkunden"
}, },
"upgrade": { "upgrade": {
"certificate": "Aktualisieren Sie Ihren Plan, um Zertifikate zu generieren", "certificate": "Aktualisieren Sie Ihren Plan, um Zertifikate zu generieren",
@@ -843,7 +844,9 @@
"share": "Aktie", "share": "Aktie",
"invite": "Einladen", "invite": "Einladen",
"delete": "Löschen" "delete": "Löschen"
} },
"get_course": "Holen Sie sich Kurs",
"learn_more": "Erfahren Sie mehr"
}, },
"course_filter": { "course_filter": {
"date_created": "Datum erstellt", "date_created": "Datum erstellt",
@@ -1369,5 +1372,10 @@
"min_char": "Die Organisation muss 5 oder mehr Zeichen enthalten", "min_char": "Die Organisation muss 5 oder mehr Zeichen enthalten",
"hyphen_rule": "Der Name der Organisation darf nicht mit einem Bindestrich beginnen oder enden" "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", "published": "Published",
"unpublished": "Unpublished", "unpublished": "Unpublished",
"continue_course": "Continue Course", "continue_course": "Continue Course",
"learn_more": "Learn more",
"list_view": { "list_view": {
"title": "Title", "title": "Title",
"description": "Description", "description": "Description",
@@ -1101,6 +1102,7 @@
"my_learning": "My Learning", "my_learning": "My Learning",
"exercise": "Exercise", "exercise": "Exercise",
"community": "Community", "community": "Community",
"explore": "Explore",
"help": "Help", "help": "Help",
"settings": "Settings" "settings": "Settings"
}, },
@@ -1125,6 +1127,12 @@
"lesson": "Lesson" "lesson": "Lesson"
}, },
"explore": {
"heading": "Explore our courses",
"empty_heading": "No course available",
"empty_description": "There are currently no published course available"
},
"snackbar": { "snackbar": {
"success": "Success", "success": "Success",
"success_update": "Updated successfully", "success_update": "Updated successfully",
@@ -185,7 +185,8 @@
"exercise": "Ejercicio", "exercise": "Ejercicio",
"community": "Comunidad", "community": "Comunidad",
"help": "Ayuda", "help": "Ayuda",
"settings": "Ajustes" "settings": "Ajustes",
"explore": "Explorar"
}, },
"upgrade": { "upgrade": {
"certificate": "Actualice su plan para generar certificados", "certificate": "Actualice su plan para generar certificados",
@@ -843,7 +844,9 @@
"share": "Compartir", "share": "Compartir",
"invite": "Invitar", "invite": "Invitar",
"delete": "Borrar" "delete": "Borrar"
} },
"get_course": "Obtener curso",
"learn_more": "Aprende más"
}, },
"course_filter": { "course_filter": {
"date_created": "fecha de creacion", "date_created": "fecha de creacion",
@@ -1369,5 +1372,10 @@
"min_char": "La organización debe contener 5 o más caracteres.", "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." "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", "exercise": "Exercice",
"community": "Communauté", "community": "Communauté",
"help": "Aide", "help": "Aide",
"settings": "Paramètres" "settings": "Paramètres",
"explore": "Explorer"
}, },
"upgrade": { "upgrade": {
"certificate": "Mettez à niveau votre plan pour générer des certificats", "certificate": "Mettez à niveau votre plan pour générer des certificats",
@@ -843,7 +844,9 @@
"share": "Partager", "share": "Partager",
"invite": "Inviter", "invite": "Inviter",
"delete": "Supprimer" "delete": "Supprimer"
} },
"get_course": "Obtenir un cours",
"learn_more": "Apprendre encore plus"
}, },
"course_filter": { "course_filter": {
"date_created": "date créée", "date_created": "date créée",
@@ -1369,5 +1372,10 @@
"min_char": "L'organisation doit contenir 5 caractères ou plus", "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" "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": "व्यायाम", "exercise": "व्यायाम",
"community": "समुदाय", "community": "समुदाय",
"help": "मदद", "help": "मदद",
"settings": "समायोजन" "settings": "समायोजन",
"explore": "अन्वेषण करना"
}, },
"upgrade": { "upgrade": {
"certificate": "प्रमाणपत्र बनाने की अपनी योजना को अपग्रेड करें", "certificate": "प्रमाणपत्र बनाने की अपनी योजना को अपग्रेड करें",
@@ -843,7 +844,9 @@
"share": "शेयर करना", "share": "शेयर करना",
"invite": "आमंत्रित करना", "invite": "आमंत्रित करना",
"delete": "मिटाना" "delete": "मिटाना"
} },
"get_course": "पाठ्यक्रम प्राप्त करें",
"learn_more": "और अधिक जानें"
}, },
"course_filter": { "course_filter": {
"date_created": "बनाया गया दिनांक", "date_created": "बनाया गया दिनांक",
@@ -1369,5 +1372,10 @@
"min_char": "संगठन में 5 या अधिक वर्ण होने चाहिए", "min_char": "संगठन में 5 या अधिक वर्ण होने चाहिए",
"hyphen_rule": "संगठन का नाम हाइफ़न से प्रारंभ या समाप्त नहीं हो सकता" "hyphen_rule": "संगठन का नाम हाइफ़न से प्रारंभ या समाप्त नहीं हो सकता"
} }
},
"explore": {
"empty_heading": "कोई पाठ्यक्रम उपलब्ध नहीं है",
"empty_description": "वर्तमान में कोई प्रकाशित पाठ्यक्रम उपलब्ध नहीं है",
"heading": "हमारे पाठ्यक्रमों का अन्वेषण करें"
} }
} }
@@ -185,7 +185,8 @@
"exercise": "Exercício", "exercise": "Exercício",
"community": "Comunidade", "community": "Comunidade",
"help": "Ajuda", "help": "Ajuda",
"settings": "Configurações" "settings": "Configurações",
"explore": "Explorar"
}, },
"upgrade": { "upgrade": {
"certificate": "Atualize seu plano para gerar certificados", "certificate": "Atualize seu plano para gerar certificados",
@@ -843,7 +844,9 @@
"share": "Compartilhar", "share": "Compartilhar",
"invite": "Convidar", "invite": "Convidar",
"delete": "Excluir" "delete": "Excluir"
} },
"get_course": "Obter curso",
"learn_more": "Saber mais"
}, },
"course_filter": { "course_filter": {
"date_created": "Data Criada", "date_created": "Data Criada",
@@ -1369,5 +1372,10 @@
"min_char": "A organização deve conter 5 ou mais caracteres", "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" "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": "Упражнение", "exercise": "Упражнение",
"community": "Сообщество", "community": "Сообщество",
"help": "Помощь", "help": "Помощь",
"settings": "Настройки" "settings": "Настройки",
"explore": "Исследовать"
}, },
"upgrade": { "upgrade": {
"certificate": "Обновите свой план для создания сертификатов", "certificate": "Обновите свой план для создания сертификатов",
@@ -843,7 +844,9 @@
"share": "Делиться", "share": "Делиться",
"invite": "Приглашать", "invite": "Приглашать",
"delete": "Удалить" "delete": "Удалить"
} },
"get_course": "Получить курс",
"learn_more": "Узнать больше"
}, },
"course_filter": { "course_filter": {
"date_created": "Дата создания", "date_created": "Дата создания",
@@ -1369,5 +1372,10 @@
"min_char": "Организация должна содержать 5 или более символов.", "min_char": "Организация должна содержать 5 или более символов.",
"hyphen_rule": "Название организации не может начинаться или заканчиваться дефисом." "hyphen_rule": "Название организации не может начинаться или заканчиваться дефисом."
} }
},
"explore": {
"empty_heading": "Нет доступных курсов",
"empty_description": "На данный момент нет доступных опубликованных курсов",
"heading": "Ознакомьтесь с нашими курсами"
} }
} }
@@ -185,7 +185,8 @@
"exercise": "Bài tập", "exercise": "Bài tập",
"community": "Cộng đồng", "community": "Cộng đồng",
"help": "Giúp đỡ", "help": "Giúp đỡ",
"settings": "Cài đặt" "settings": "Cài đặt",
"explore": "Khám phá"
}, },
"upgrade": { "upgrade": {
"certificate": "Nâng cấp gói của bạn để tạo chứng chỉ", "certificate": "Nâng cấp gói của bạn để tạo chứng chỉ",
@@ -843,7 +844,9 @@
"share": "Chia sẻ", "share": "Chia sẻ",
"invite": "Mời", "invite": "Mời",
"delete": "Xóa bỏ" "delete": "Xóa bỏ"
} },
"get_course": "Nhận khóa học",
"learn_more": "Tìm hiểu thêm"
}, },
"course_filter": { "course_filter": {
"date_created": "Ngày tạo", "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", "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" "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) => { filteredCourses = courses.filter((course) => {
if (!searchValue || course.title.toLowerCase().includes(searchValue)) { if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
return true; 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$
;