feat: add 4 certificate templates (#295)

* add cloud translation api package

* feat: Script that auto generates translations for other languages

* add comments

* fix: broken translation

* improve translation

* update locck file

* clean up

* all round trasnlations check

* checkpoint

* finish up

* move to dev dependencies

* fix vercel build error

* translations fix

* - add documentation
- translation bug fix

* quick adjust

* PR Fixes

* add new line

* update docs

* checkpoint

* feat: redesign & addition of certificates

* text color update

* patterned width fix

* "student-name" class remove

* checkpoint

* PR Fix

* quick fix

* PR Fixes

* update certificate ready page for students

* PR Fixes

* quick cleanup

* move fetchCourses into .ts file

* feat: give students access to certificates only after completing course

* checkpoint

* height fix

* Merge branch 'main' of https://github.com/rotimi-best/classroomio into classroomio-issue-272

* Fix; merge PR Fix

* fix: width of plain & professional certs

* PR Fix

* feat: add translation

* handle error when trying to download a certificate

* add fetchExploreCourses

* fix: requested reviews

* review fixes checkpoint

* replace the certificate background

* height fix for patterned certififcates

* fix more bugs

* rename schema

---------

Co-authored-by: Rotimi Best <best@classroomio.com>
This commit is contained in:
Adeyemi Adetunji
2024-07-21 03:02:33 +01:00
committed by GitHub
co-authored by Rotimi Best
parent f0907dde89
commit 72b58ed2c7
48 changed files with 2940 additions and 251 deletions
@@ -1,41 +1,79 @@
<script>
import { RadioButtonGroup, RadioButton, Toggle } from 'carbon-components-svelte';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import { goto } from '$app/navigation';
import { updateCourse } from '$lib/utils/services/courses';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import UpgradeBanner from '$lib/components/Upgrade/Banner.svelte';
import TextArea from '$lib/components/Form/TextArea.svelte';
import Professional from './certificates/Professional.svelte';
import Plain from './certificates/Plain.svelte';
import Professional from './templates/Professional.svelte';
import Plain from './templates/Plain.svelte';
import { course } from '$lib/components/Course/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { globalStore } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import FlashFilled from 'carbon-icons-svelte/lib/FlashFilled.svelte';
import PurpleProfessionalBadge from './templates/PurpleProfessionalBadge.svelte';
import BlueProfessionalBadge from './templates/BlueProfessionalBadge.svelte';
import PurpleBadgePattern from './templates/PurpleBadgePattern.svelte';
import BlueBadgePattern from './templates/BlueBadgePattern.svelte';
import { snackbar } from '$lib/components/Snackbar/store';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
const studentNamePlaceholder = 'Name of student';
const themes = ['professional', 'plain'];
const themes = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
];
let isSaving = false;
let errors = {
description: ''
};
let helperText = '';
const saveCertificate = async () => {
isSaving = true;
if (!$course.description) {
errors.description = 'Description cannot be empty';
try {
const result = saveCertificateValidation({
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
certificate_theme: $course.certificate_theme || ''
});
if (result && Object.keys(result).length > 0) {
errors.description =
$t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description);
}
errors.description = '';
await updateCourse($course.id, undefined, {
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
certificate_theme: $course.certificate_theme || ''
});
snackbar.success('snackbar.course_settings.success.saved');
} catch (error) {
if (error.message) {
errors.description = error.message;
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
} finally {
isSaving = false;
return;
}
await updateCourse($course.id, undefined, {
description: $course.description || '',
is_certificate_downloadable: $course.is_certificate_downloadable || false,
certificate_theme: $course.certificate_theme || ''
});
isSaving = false;
};
$: helperText = `${$course.description?.length || 0}/200 ${$t(
'course.navItem.certificates.characters'
)}`;
</script>
<svelte:head>
@@ -63,16 +101,18 @@
class="mb-10"
disabled={$isFreePlan}
>
{#each themes as theme}
<div class="flex mr-3">
<RadioButton value={theme} />
<img
src={`/images/certificate_theme_${theme}.png`}
alt="themes"
class="w-[110px] h-[82px]"
/>
</div>
{/each}
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="flex mr-3">
<RadioButton value={theme} />
<img
src={`/images/certificate_theme_${theme}.png`}
alt="themes"
class="w-[110px] h-[82px]"
/>
</div>
{/each}
</div>
</RadioButtonGroup>
<div>
<p class="text-xs font-normal text-black my-2 dark:text-gray-100">
@@ -102,6 +142,7 @@
bind:value={$course.description}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
/>
</span>
<Toggle
@@ -121,11 +162,19 @@
<section
class="bg-gray-100 dark:bg-neutral-800 flex justify-center items-center rounded-md w-full lg:w-3/5"
>
<div class="certificate-container flex justify-center items-center h-5/6">
<div class="certificate-container flex justify-center items-center">
{#if $course.certificate_theme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else}
{:else if $course.certificate_theme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if $course.certificate_theme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
@@ -5,7 +5,7 @@
<Box>
<div class="flex flex-col items-center justify-center w-max h-full gap-2">
<img src="/professional.png" alt="Certificate" />
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-xl font-normal text-center">
{$t('course.navItem.certificates.locked_certificate')}
</p>
@@ -1,59 +1,107 @@
<script>
<script lang="ts">
import { onMount } from 'svelte';
import Download from 'carbon-icons-svelte/lib/Download.svelte';
import { PUBLIC_SERVER_URL } from '$env/static/public';
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import Box from '$lib/components/Box/index.svelte';
import { t } from '$lib/utils/functions/translations';
import { fetchProfileCourseProgress } from '$lib/utils/services/courses';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { snackbar } from '$lib/components/Snackbar/store';
let isLoading = false;
let isCourseComplete = false;
let progress: ProfileCourseProgress | undefined;
const downLoadCertificate = async () => {
isLoading = true;
const response = await fetch(PUBLIC_SERVER_URL + '/downloadCertificate', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgName: `${$currentOrg.name}`
})
});
const data = await response.blob();
console.log(data);
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
if (!isCourseComplete) return;
let a = document.createElement('a');
document.body.append(a);
a.download = 'Certificate of Completion - ' + $currentOrg.name;
a.href = fileURL;
a.click();
a.remove();
isLoading = true;
try {
const response = await fetch(PUBLIC_SERVER_URL + '/downloadCertificate', {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
theme: `${$course.certificate_theme}`,
studentName: `${$profile.fullname}`,
courseName: `${$course.title}`,
courseDescription: `${$course.description}`,
orgLogoUrl: `${$currentOrg.avatar_url}`,
orgName: `${$currentOrg.name}`
})
});
const data = await response.blob();
console.log(data);
const file = new Blob([data], { type: 'application/pdf' });
const fileURL = URL.createObjectURL(file);
let a = document.createElement('a');
document.body.append(a);
a.download = 'Certificate of Completion - ' + $currentOrg.name;
a.href = fileURL;
a.click();
a.remove();
} catch (error) {
console.error('Error downloading', error);
snackbar.error($t('course.navItem.certificates.unexpected_error'));
}
isLoading = false;
};
const hasUserCompletedCourse = async () => {
isLoading = true;
const { data } = await fetchProfileCourseProgress($course.id, $profile.id);
progress = data?.[0] || undefined;
if (progress) {
isCourseComplete =
progress.lessons_count === progress.lessons_completed &&
progress.exercises_count === progress.exercises_completed;
}
isLoading = false;
};
onMount(() => {
hasUserCompletedCourse();
});
$: title = isCourseComplete
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title';
$: subtitle = isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle';
</script>
<Box>
<div class="flex flex-col items-center justify-center w-max h-full gap-5">
<img src="/professional.png" alt="Certificate" />
<img src="/images/student-certificate-preview.png" alt="Certificate" class="max-w-[218px]" />
<p class="text-xl font-normal text-center">
{$t('course.navItem.certificates.unlocked_certificate')}
{$t(title)}
</p>
<p class="text-sm font-normal text-center max-w-md">
{$t(subtitle)}
</p>
<PrimaryButton
label={$t('course.navItem.certificates.download_certificate')}
className="rounded-md"
className="flex items-center gap-2"
onClick={downLoadCertificate}
isDisabled={!PUBLIC_SERVER_URL}
variant={VARIANTS.CONTAINED_DARK}
isDisabled={!PUBLIC_SERVER_URL || !isCourseComplete}
{isLoading}
/>
>
<Download size={16} />
{$t('course.navItem.certificates.download_certificate')}
</PrimaryButton>
</div>
</Box>
@@ -0,0 +1,60 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full relative dark:text-black">
<img
src="/images/blue-badge-pattern-background.svg"
alt=""
class="w-full h-full absolute z-0 object-cover"
/>
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
<div class="w-[70%] pl-3">
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-16">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="font-bold mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="text-lg m-0">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center my-8 text-center">
<div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p>
</div>
</div>
<footer class="text-[8px] font-bold w-[90%] desc">
{$course.description}
</footer>
</div>
<div class="overflow-hidden flex justify-center items-center">
<img
src="/images/blue-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
</div>
</div>
</div>
@@ -0,0 +1,51 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="p-3 w-full dark:text-black bg-[#2D499F]">
<div class="w-full bg-white pb-5 flex">
<div class="w-[80%] pl-3">
<header class="flex justify-start items-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-7">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#2D499F]">{studentName}</h1>
</div>
<div class="font-bold mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="text-lg m-0">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center mt-8 mb-5 text-center">
<div class="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p>
</div>
</div>
<footer class="text-[8px] font-bold w-[90%] desc">
{$course.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -3,13 +3,13 @@
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
let borderwidth = 'border-4';
let bordercolor = 'border-pink-700';
let logo = '/logo-512.png';
const borderwidth = 'border-4';
const bordercolor = 'border-pink-700';
const logo = '/logo-512.png';
</script>
<div
class="certificate-bg flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
class="certificate-bg w-full flex flex-col items-center justify-center {borderwidth} {bordercolor} py-3"
>
<div class="flex items-center gap-1 my-2">
<img
@@ -3,15 +3,15 @@
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
let borderwidth = 'border-l-8';
let bordercolor = 'border-primary-800';
let logo = '/logo-512.png';
const borderwidth = 'border-l-8';
const bordercolor = 'border-primary-800';
const logo = '/logo-512.png';
</script>
<div
class="certificate-bg flex items-center justify-center border border-dashed border-primary-400"
class="certificate-bg w-full flex items-center justify-center border border-dashed border-primary-400"
>
<div class="{borderwidth} {bordercolor}">
<div class="{borderwidth} {bordercolor} w-full">
<div class="w-full px-5 py-3">
<p class="text-sm font-normal my-2 uppercase text-gray-500">
Certificate<br /> of completion
@@ -0,0 +1,60 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="w-full relative dark:text-black">
<img
src="/images/purple-badge-pattern-background.svg"
alt=""
class="w-full h-full absolute z-0 object-cover"
/>
<div class="relative md:h-[17rem] h-full bg-white flex justify-between z-20 m-2">
<div class="w-[70%] pl-3">
<header class="absolute w-full flex justify-center items-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-16">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#914397]">{studentName}</h1>
</div>
<div class="font-bold mt-3 w-[90%]">
<h1 class="text-[7px]">has successfully completed training on</h1>
<h2 class="text-lg m-0">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center my-8 text-center">
<div>
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p>
</div>
</div>
<footer class="text-[8px] font-bold w-[90%] desc">
{$course.description}
</footer>
</div>
<div class="overflow-hidden flex justify-center items-center">
<img
src="/images/purple-badge-pattern-badge.svg"
alt="A badge"
class="max-w-none w-[8rem] md:w-[10rem]"
/>
</div>
</div>
</div>
@@ -0,0 +1,51 @@
<script>
import { course } from '$lib/components/Course/store';
import { currentOrg } from '$lib/utils/store/org';
export let studentName = '';
const logo = '/logo-512.png';
const dateIssued = new Date().toLocaleDateString();
</script>
<div class="p-3 w-full dark:text-black bg-[#95449A]">
<div class="w-full bg-white pb-5 flex">
<div class="w-[80%] pl-3">
<header class="flex justify-start items-center gap-1 text-sm font-bold">
<img src={$currentOrg.avatar_url ? $currentOrg.avatar_url : logo} alt="logo" class="w-5" />
<h1 class="text-sm">{$currentOrg.name}</h1>
</header>
<div class="font-bold mt-7">
<h2 class="text-[7px] m-0">This certificate is awarded to</h2>
<h1 class="text-lg m-0 mt-0.5 leading-3 text-[#95449A]">{studentName}</h1>
</div>
<div class="font-bold mt-3 w-[90%]">
<h1 class="text-[7px]">has succesfully completed training on</h1>
<h2 class="text-lg m-0">
{$course.title}
</h2>
</div>
<div class="flex justify-between items-center mt-8 mb-5 text-center">
<div class="">
<h1 class="h-2/4 m-0 text-[7px]">DATE ISSUED:</h1>
<p class="border-b border-black font-bold text-[7px]">{dateIssued}</p>
</div>
<div class="w-[20%] text-[7px]">
<div class=""></div>
<p class="border-t border-black font-bold text-[7px]">Signature</p>
</div>
</div>
<footer class="text-[8px] font-bold w-[90%] desc">
{$course.description}
</footer>
</div>
<div class="w-[20%]">
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
</div>
</div>
</div>
@@ -1,47 +0,0 @@
import { supabase } from '$lib/utils/functions/supabase';
import { get } from 'svelte/store';
import { isOrgAdmin } from '$lib/utils/store/org';
export async function fetchCourses(profileId, orgId) {
if (!orgId || !profileId) return;
const match = {};
// Filter by profile_id if role isn't admin within organization
if (!get(isOrgAdmin)) {
match.member_profile_id = profileId;
}
// Gets courses for a particular organisation where the current logged in user is a groupmember
const { data: allCourses } = await supabase
.rpc('get_courses', {
org_id_arg: orgId,
profile_id_arg: profileId
})
.match(match);
console.log(`allCourses`, allCourses);
if (!Array.isArray(allCourses)) {
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 };
}
@@ -9,7 +9,7 @@
import { currentOrgPath, currentOrg } from '$lib/utils/store/org';
import { supabase } from '$lib/utils/functions/supabase';
import { calDateDiff } from '$lib/utils/functions/date';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { profile } from '$lib/utils/store/user';
import { Search, Dropdown } from 'carbon-components-svelte';
import { t } from '$lib/utils/functions/translations';
@@ -3,23 +3,31 @@
import { group } from '../Course/store';
import type { GroupPerson } from '$lib/utils/types';
import { isOrgAdmin } from '$lib/utils/store/org';
import { ROLE } from '$lib/utils/constants/roles';
export let allowedRoles: number[] = [];
export let onDenied = () => {};
export let onlyStudent = false;
let userRole: number = 0;
function isAllowed(userRole) {
return allowedRoles.includes(userRole);
}
$: {
const user: GroupPerson = $group.people.find((person) => person.profile_id === $profile.id)!;
userRole = user ? user.role_id : userRole;
if (!$isOrgAdmin && $group.people.length && !allowedRoles.includes(userRole)) {
if (!$isOrgAdmin && $group.people.length && !isAllowed(userRole)) {
onDenied();
}
}
$: show = onlyStudent ? isAllowed(ROLE.STUDENT) : isAllowed(userRole) || $isOrgAdmin;
</script>
{#if allowedRoles.includes(userRole) || $isOrgAdmin}
{#if show}
<slot />
{/if}
@@ -125,6 +125,17 @@ const onboardingValidationSchema = {
})
};
const saveCertificateSchema = z.object({
description: z.string().max(200, 'course.navItem.certificates.description_error'),
is_certificate_downloadable: z.boolean(),
certificate_theme: z.string()
});
export const saveCertificateValidation = (fields = {}) => {
const { error } = saveCertificateSchema.safeParse(fields);
return processErrors(error);
};
const updateProfileValidationSchema = z.object({
email: z.string().email({ message: 'validations.user_profile.email' }),
username: z.string().nonempty({ message: 'validations.user_profile.username' }),
@@ -1,3 +1,4 @@
import { get } from 'svelte/store';
import { supabase } from '$lib/utils/functions/supabase';
import { isUUID } from '$lib/utils/functions/isUUID';
import { QUESTION_TYPE } from '$lib/components/Question/constants';
@@ -11,7 +12,53 @@ import type {
ExerciseTemplate
} from '$lib/utils/types';
import { STATUS } from '$lib/utils/constants/course';
import type { PostgrestSingleResponse } from '@supabase/supabase-js';
import type { PostgrestSingleResponse, PostgrestError } from '@supabase/supabase-js';
import type { ProfileCourseProgress } from '$lib/utils/types';
import { isOrgAdmin } from '$lib/utils/store/org';
export async function fetchCourses(profileId, orgId) {
if (!orgId || !profileId) return;
const match = {};
// Filter by profile_id if role isn't admin within organization
if (!get(isOrgAdmin)) {
match.member_profile_id = profileId;
}
// Gets courses for a particular organisation where the current logged in user is a groupmember
const { data: allCourses } = await supabase
.rpc('get_courses', {
org_id_arg: orgId,
profile_id_arg: profileId
})
.match(match);
console.log(`allCourses`, allCourses);
if (!Array.isArray(allCourses)) {
return {
allCourses: []
};
}
return { allCourses };
}
export async function fetchProfileCourseProgress(
courseId,
profileId
): Promise<{
data: ProfileCourseProgress[] | null;
error: PostgrestError | null;
}> {
const { data, error } = await supabase
.rpc('get_course_progress', {
course_id_arg: courseId,
profile_id_arg: profileId
})
.returns<ProfileCourseProgress[]>();
return { data, error };
}
const SLUG_QUERY = `
id,
@@ -95,6 +142,23 @@ export async function fetchCourse(courseId?: Course['id'], slug?: Course['slug']
};
}
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 };
}
export async function fetchGroup(groupId: Group['id']) {
const { data, error } = await supabase
.from('group')
@@ -409,11 +409,17 @@
"locked": "Gesperrt",
"unlocked": "Entsperrt",
"save": "Änderungen speichern",
"unlocked_certificate": "Zertifikat ist jetzt verfügbar",
"locked_certificate": "Zertifikate nicht verfügbar",
"instructor_unlock": "Ihr Dozent muss Ihr Zertifikat entsperren.",
"contact_instructor": "Wenn Sie Ihr Zertifikat benötigen, wenden Sie sich bitte an Ihren Dozenten.",
"download_certificate": "Zertifikat herunterladen"
"download_certificate": "Zertifikat herunterladen",
"unlocked_certificate": "Glückwunsch! 🎉 Dein Zertifikat ist fertig",
"unlocked_certificate_subtitle": "Sie haben diesen Kurs abgeschlossen und Ihr Engagement ist offensichtlich. Laden Sie das Zertifikat herunter und teilen Sie es mit Ihrem Netzwerk als Beweis für Ihre neu erworbenen Fähigkeiten",
"complete_to_download_subtitle": "Ihr Zertifikat ist verfügbar, sobald Sie alle Lektionen und Übungen dieses Kurses abgeschlossen haben",
"complete_to_download_title": "Kurs nicht abgeschlossen 😔",
"description_error": "Die Beschreibung darf 200 Zeichen nicht überschreiten",
"characters": "Figuren",
"unexpected_error": "Ein unerwarteter Fehler ist aufgetreten"
},
"people": {
"title": "Menschen",
@@ -855,7 +855,7 @@
"theme": "Choose a theme",
"logo": "Brand Logo",
"to_update": "To update your brand image, go to",
"settings": "Settings &gt; Organisation settings",
"settings": "Settings > Organisation settings",
"and_upload": "and upload your brand logo",
"goto_settings": "Go to Settings",
"description": "Course Description",
@@ -864,11 +864,17 @@
"locked": "Locked",
"unlocked": "Unlocked",
"save": "Save Changes",
"unlocked_certificate": "Certificate is now available",
"unlocked_certificate": "Congrats! 🎉 your certificate is ready",
"unlocked_certificate_subtitle": "You have completed this course, and your dedication is evident. Download certificate and share with your network as a prove of your newly acquired skill",
"locked_certificate": "Certificates not available",
"instructor_unlock": "Your instructor needs to unlock your certificate.",
"contact_instructor": "If you need your certificate, please contact your instructor.",
"download_certificate": "DownLoad Certificate"
"download_certificate": "Download Certificate",
"complete_to_download_title": "Course not complete 😔",
"complete_to_download_subtitle": "Your certificate will be available once you complete all lesson and exercises in this course",
"description_error": "Description cannot exceed 200 characters",
"characters": "characters",
"unexpected_error": "An unexpected error occurred"
},
"landing_page": {
@@ -409,11 +409,17 @@
"locked": "bloqueado",
"unlocked": "desbloqueado",
"save": "Guardar cambios",
"unlocked_certificate": "El certificado ya está disponible",
"locked_certificate": "Certificados no disponibles",
"instructor_unlock": "Su instructor necesita desbloquear su certificado.",
"contact_instructor": "Si necesita su certificado, comuníquese con su instructor.",
"download_certificate": "Descargar certificado"
"download_certificate": "Descargar certificado",
"unlocked_certificate": "¡Felicitaciones! 🎉 tu certificado está listo",
"unlocked_certificate_subtitle": "Has completado este curso y tu dedicación es evidente. Descargue el certificado y compártalo con su red como prueba de su habilidad recién adquirida",
"complete_to_download_subtitle": "Su certificado estará disponible una vez que complete todas las lecciones y ejercicios de este curso.",
"complete_to_download_title": "Curso no completo 😔",
"description_error": "La descripción no puede exceder los 200 caracteres.",
"characters": "caracteres",
"unexpected_error": "ocurrió un error inesperado"
},
"people": {
"title": "Gente",
@@ -409,11 +409,17 @@
"locked": "Fermé à clé",
"unlocked": "Débloqué",
"save": "Sauvegarder les modifications",
"unlocked_certificate": "Le certificat est maintenant disponible",
"locked_certificate": "Certificats non disponibles",
"instructor_unlock": "Votre instructeur doit débloquer votre certificat.",
"contact_instructor": "Si vous avez besoin de votre certificat, veuillez contacter votre instructeur.",
"download_certificate": "Télécharger le certificat"
"download_certificate": "Télécharger le certificat",
"unlocked_certificate": "Bravo! 🎉 votre attestation est prête",
"unlocked_certificate_subtitle": "Vous avez terminé ce cours et votre dévouement est évident. Téléchargez le certificat et partagez-le avec votre réseau comme preuve de vos compétences nouvellement acquises",
"complete_to_download_subtitle": "Votre certificat sera disponible une fois que vous aurez terminé toutes les leçons et exercices de ce cours.",
"complete_to_download_title": "Cours non terminé 😔",
"description_error": "La description ne peut pas dépasser 200 caractères",
"characters": "personnages",
"unexpected_error": "une erreur inattendue est apparue"
},
"people": {
"title": "Personnes",
@@ -409,11 +409,17 @@
"locked": "बंद",
"unlocked": "अनलॉक किया",
"save": "परिवर्तनों को सुरक्षित करें",
"unlocked_certificate": "प्रमाणपत्र अब उपलब्ध है",
"locked_certificate": "प्रमाणपत्र उपलब्ध नहीं हैं",
"instructor_unlock": "आपके प्रशिक्षक को आपका प्रमाणपत्र अनलॉक करना होगा.",
"contact_instructor": "यदि आपको अपने प्रमाणपत्र की आवश्यकता है, तो कृपया अपने प्रशिक्षक से संपर्क करें।",
"download_certificate": "प्रमाणपत्र डाउनलोड करें"
"download_certificate": "प्रमाणपत्र डाउनलोड करें",
"unlocked_certificate_subtitle": "आपने यह पाठ्यक्रम पूरा कर लिया है, और आपका समर्पण स्पष्ट है। प्रमाणपत्र डाउनलोड करें और अपने नए अर्जित कौशल के प्रमाण के रूप में अपने नेटवर्क के साथ साझा करें",
"unlocked_certificate": "बधाई! 🎉आपका सर्टिफिकेट तैयार है",
"complete_to_download_subtitle": "इस पाठ्यक्रम के सभी पाठ और अभ्यास पूरा करने के बाद आपका प्रमाणपत्र उपलब्ध हो जाएगा",
"complete_to_download_title": "कोर्स पूरा नहीं हुआ 😔",
"description_error": "विवरण 200 वर्णों से अधिक नहीं हो सकता",
"characters": "पात्र",
"unexpected_error": "एक अप्रत्याशित त्रुटि हुई"
},
"people": {
"title": "लोग",
@@ -409,11 +409,17 @@
"locked": "Bloqueado",
"unlocked": "Desbloqueado",
"save": "Salvar alterações",
"unlocked_certificate": "Certificado já está disponível",
"locked_certificate": "Certificados não disponíveis",
"instructor_unlock": "Seu instrutor precisa desbloquear seu certificado.",
"contact_instructor": "Se precisar do seu certificado, entre em contato com seu instrutor.",
"download_certificate": "Certificado de download"
"download_certificate": "Certificado de download",
"unlocked_certificate": "Parabéns! 🎉 seu certificado está pronto",
"unlocked_certificate_subtitle": "Você concluiu este curso e sua dedicação é evidente. Baixe o certificado e compartilhe com sua rede como prova de sua habilidade recém-adquirida",
"complete_to_download_subtitle": "Seu certificado estará disponível assim que você concluir todas as lições e exercícios deste curso",
"complete_to_download_title": "Curso não concluído 😔",
"description_error": "A descrição não pode exceder 200 caracteres",
"characters": "personagens",
"unexpected_error": "Um erro inesperado ocorreu"
},
"people": {
"title": "Pessoas",
@@ -409,11 +409,17 @@
"locked": "Заблокировано",
"unlocked": "Разблокировано",
"save": "Сохранить изменения",
"unlocked_certificate": "Сертификат уже доступен",
"locked_certificate": "Сертификаты недоступны",
"instructor_unlock": "Преподавателю необходимо разблокировать ваш сертификат.",
"contact_instructor": "Если вам нужен сертификат, обратитесь к преподавателю.",
"download_certificate": "Скачать сертификат"
"download_certificate": "Скачать сертификат",
"unlocked_certificate": "Поздравляю! 🎉ваш сертификат готов",
"unlocked_certificate_subtitle": "Вы прошли этот курс, и ваша преданность делу очевидна. Загрузите сертификат и поделитесь им со своей сетью в качестве доказательства вашего недавно приобретенного навыка.",
"complete_to_download_subtitle": "Ваш сертификат будет доступен после завершения всех уроков и упражнений этого курса.",
"complete_to_download_title": "Курс не завершен 😔",
"description_error": "Описание не может превышать 200 символов.",
"characters": "персонажи",
"unexpected_error": "произошла непредвиденная ошибка"
},
"people": {
"title": "Люди",
@@ -409,11 +409,17 @@
"locked": "Đã khóa",
"unlocked": "Đã mở khóa",
"save": "Lưu thay đổi",
"unlocked_certificate": "Chứng chỉ hiện có sẵn",
"locked_certificate": "Chứng chỉ không có sẵn",
"instructor_unlock": "Người hướng dẫn của bạn cần mở khóa chứng chỉ của bạn.",
"contact_instructor": "Nếu bạn cần chứng chỉ của mình, vui lòng liên hệ với người hướng dẫn của bạn.",
"download_certificate": "Tải xuống chứng chỉ"
"download_certificate": "Tải xuống chứng chỉ",
"unlocked_certificate": "Chúc mừng! 🎉 chứng chỉ của bạn đã sẵn sàng",
"unlocked_certificate_subtitle": "Bạn đã hoàn thành khóa học này và sự cống hiến của bạn là điều hiển nhiên. Tải xuống chứng chỉ và chia sẻ với mạng của bạn như một bằng chứng về kỹ năng mới có được của bạn",
"complete_to_download_subtitle": "Chứng chỉ của bạn sẽ có sau khi bạn hoàn thành tất cả bài học và bài tập trong khóa học này",
"complete_to_download_title": "Khóa học chưa hoàn thành 😔",
"description_error": "Mô tả không thể vượt quá 200 ký tự",
"characters": "nhân vật",
"unexpected_error": "Đã xảy ra lỗi không mong muốn"
},
"people": {
"title": "Mọi người",
@@ -13,6 +13,14 @@ export enum LOCALE {
}
//===============Custom Type===============
export interface ProfileCourseProgress {
exercises_completed: number;
exercises_count: number;
lessons_completed: number;
lessons_count: number;
}
export interface GroupPerson {
assigned_student_id: number | null;
created_at: string;
@@ -13,12 +13,12 @@
import StudentCertificate from '$lib/components/Course/components/Ceritficate/StudentCertificate/Index.svelte';
import { t } from '$lib/utils/functions/translations';
export let courseId: string = '';
export let data;
</script>
<IssueCertificateModal />
<CourseContainer bind:courseId>
<CourseContainer bind:courseId={data.courseId}>
<PageNav title={$t('course.navItem.certificates.title')} disableSticky={true}>
<slot:fragment slot="widget">
<RoleBasedSecurity allowedRoles={[1, 2]}>
@@ -32,11 +32,11 @@
</slot:fragment>
</PageNav>
<PageBody className="w-full mx-0" padding="px-3">
<PageBody className="w-full mx-0" padding="px-3 pb-10">
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
</RoleBasedSecurity>
<RoleBasedSecurity allowedRoles={[3]}>
<RoleBasedSecurity onlyStudent>
<StudentCertificate />
</RoleBasedSecurity>
+1 -1
View File
@@ -5,7 +5,7 @@
import { goto } from '$app/navigation';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
import { getGreeting } from '$lib/utils/functions/date';
@@ -23,7 +23,7 @@
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
import { browser } from '$app/environment';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
@@ -11,7 +11,6 @@
import TextEditor from '$lib/components/TextEditor/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/components/Courses/api';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
@@ -2,10 +2,10 @@
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 { fetchExploreCourses } from '$lib/utils/services/courses';
import Courses from '$lib/components/Courses/index.svelte';
import { courses, courseMetaDeta } from '$lib/components/Courses/store';
import { 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';
@@ -20,6 +20,7 @@
let filteredExploreCourses: Course[];
let hasFetched = false;
let exploreCourseList: Course[] = [];
async function getCourses(userId: string | null, orgId: string) {
if (hasFetched || !userId || !orgId) {
return;
@@ -3,7 +3,7 @@
import Tabs from '$lib/components/Tabs/index.svelte';
import TabContent from '$lib/components/TabContent/index.svelte';
import Courses from '$lib/components/Courses/index.svelte';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { profile } from '$lib/utils/store/user';
import { currentOrg } from '$lib/utils/store/org';
import {
@@ -23,7 +23,7 @@
import TextEditor from '$lib/components/TextEditor/index.svelte';
import { calDateDiff } from '$lib/utils/functions/date';
import { browser } from '$app/environment';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
@@ -12,7 +12,7 @@
import TextEditor from '$lib/components/TextEditor/index.svelte';
import TextField from '$lib/components/Form/TextField.svelte';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import { t } from '$lib/utils/functions/translations';
import { courses } from '$lib/components/Courses/store';
import type { Course } from '$lib/utils/types';
@@ -1,7 +1,7 @@
<script lang="ts">
import { Search, Dropdown } from 'carbon-components-svelte';
import { profile } from '$lib/utils/store/user';
import { fetchCourses } from '$lib/components/Courses/api';
import { fetchCourses } from '$lib/utils/services/courses';
import Courses from '$lib/components/Courses/index.svelte';
import NewCourseModal from '$lib/components/Courses/components/NewCourseModal/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';