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>