feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -0,0 +1,134 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Label } from '@cio/ui/base/label';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
const studentNamePlaceholder = 'Name of student';
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
function onDescriptionInput(e: Event) {
if (!courseApi.course) return;
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
}
function goToOrgSettings() {
goto(
resolve('/org/[slug]/settings', {
slug: $currentOrg.siteName || ''
})
);
}
</script>
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
<RadioGroup.Root
value={certificateTheme}
onValueChange={(value) => {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
theme: value as CertificateThemeId
};
}}
disabled={$isFreePlan}
class="mb-6"
>
<div class="flex flex-wrap justify-between gap-y-5">
{#each CERTIFICATE_THEME_IDS as theme (theme)}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
<Field.Description>
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</Field.Description>
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</Field.Field>
<Field.Field>
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
<Textarea
id="cert-design-description"
rows={6}
class="w-full"
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description ?? ''}
oninput={onDescriptionInput}
disabled={$isFreePlan}
/>
<Field.Description>{helperText}</Field.Description>
{#if errors.description}
<Field.Error>{errors.description}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
</section>
<section class="flex w-full rounded-md lg:w-3/5">
<div class="w-[95%]">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { t } from '$lib/utils/functions/translations';
import { UpgradeBanner } from '$features/ui';
import CertificateDesign from './certificate-design.svelte';
import CertificateSettings from './certificate-settings.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
let activeTab = $state('design');
</script>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="px-2 md:-mr-3 md:-ml-3">
<UnderlineTabs.Root bind:value={activeTab} class="w-full">
<UnderlineTabs.List>
<UnderlineTabs.Trigger value="design">
{$t('course.navItem.certificates.tab_design')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.certificates.tab_settings')}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="design" class="mt-4">
<CertificateDesign {errors} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings" class="mt-4">
<CertificateSettings {errors} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</main>
@@ -0,0 +1,186 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { isFreePlan } from '$lib/utils/store/org';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
function isoToDatetimeLocal(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
...patch
};
}
function onCertDeadlineChange(e: Event) {
const v = (e.currentTarget as HTMLInputElement).value;
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
}
const certExercises = $derived(
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
);
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
function onFinalExerciseChange(e: Event) {
const v = (e.currentTarget as HTMLSelectElement).value;
if (!v || v === 'none') {
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
return;
}
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
updateCertificate({
requiredExerciseId: v,
exerciseMinScorePercent: currentMin ?? 100
});
}
function onThresholdInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onMinExerciseScoreInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onEmailMessageInput(e: Event) {
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
}
</script>
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.certification.rules_heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.certificate?.isDownloadable}
onCheckedChange={(checked) => {
updateCertificate({ isDownloadable: checked });
}}
disabled={$isFreePlan}
/>
<Field.Label for="certificate-downloadable" class="text-gray-600">
{$t('course.navItem.certificates.allow')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
<Input
id="cert-deadline"
type="datetime-local"
class="w-full"
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
onchange={onCertDeadlineChange}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
{#if errors['certificate.deadline']}
<Field.Error>{errors['certificate.deadline']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
<Input
id="cert-threshold"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.threshold ?? 100)}
oninput={onThresholdInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
{#if errors['certificate.threshold']}
<Field.Error>{errors['certificate.threshold']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
<select
id="cert-final-exercise"
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
value={finalExerciseSelectValue}
onchange={onFinalExerciseChange}
disabled={$isFreePlan}
>
<option value="none">{$t('course.certification.final_exercise_none')}</option>
{#each certExercises as item (item.id)}
<option value={item.id}>{item.title}</option>
{/each}
</select>
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
</Field.Field>
{#if courseApi.course?.certificate?.requiredExerciseId}
<Field.Field>
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
<Input
id="cert-min-exercise-score"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
oninput={onMinExerciseScoreInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
{#if errors['certificate.exerciseMinScorePercent']}
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
{/if}
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.certification.email_message_label')}</Field.Legend>
<Field.Field>
<Textarea
id="cert-email-message"
class="w-full"
rows={4}
placeholder={$t('course.certification.email_message_placeholder')}
value={courseApi.course?.certificate?.emailMessage ?? ''}
oninput={onEmailMessageInput}
disabled={$isFreePlan}
/>
{#if errors['certificate.emailMessage']}
<Field.Error>{errors['certificate.emailMessage']}</Field.Error>
{/if}
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Confetti } from '$features/ui';
import { setConfetti } from '$features/ui/confetti/store';
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
let activeCourseId = $state('');
onMount(() => {
return courseCompletionModal.subscribe((v) => {
if (v?.open && v.courseId) {
activeCourseId = v.courseId;
open = true;
setConfetti(true);
setTimeout(() => setConfetti(false), 5500);
} else {
open = false;
}
});
});
function onOpenChange(next: boolean) {
open = next;
if (!next) {
closeCourseCompletionModal();
}
}
function goToCertificate() {
if (!activeCourseId) return;
onOpenChange(false);
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
}
</script>
<div class="pointer-events-none fixed inset-0 z-100">
<Confetti />
</div>
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="ui:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,196 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
const studentNamePlaceholder = 'Name of student';
const themes = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
];
let errors = $state({
description: ''
});
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
const saveCertificate = async () => {
if (!courseApi.course) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
return;
}
try {
const result = saveCertificateValidation({
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
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 courseApi.update(courseApi.course.id!, {
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
snackbar.success('snackbar.course_settings.success.saved');
} catch (error) {
if (error instanceof Error) {
errors.description = error.message;
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
}
};
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>{$t('course.navItem.certificates.certificate_settings')}</strong
>
<p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')}
</p>
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
<div>
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
{$t('course.navItem.certificates.logo')}
</p>
<div>
<p class="mt-1 text-base dark:text-gray-100">
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</p>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description}
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
/>
</span>
<div class="my-4 flex items-center space-x-2">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.isCertificateDownloadable}
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
disabled={$isFreePlan}
/>
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.certificates.allow')}
</Label>
</div>
</div>
</section>
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
<div class="certificate-container flex items-center justify-center">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
</div>
</main>
<style>
p {
color: #262626;
}
.certificate-container {
width: 95%;
max-width: 95%;
}
</style>
@@ -5,7 +5,7 @@
</script>
<div>
{#if courseApi.course?.isCertificateDownloadable === true}
{#if courseApi.course?.certificate?.isDownloadable === true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
@@ -5,17 +5,55 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
let isCourseComplete = $state(false);
let evaluation = $state<CertificationEvaluationData | null>(null);
let courseId = $derived(courseApi.course?.id ?? '');
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
const downLoadCertificate = async () => {
if (!isCourseComplete || !courseId) return;
@@ -24,7 +62,7 @@
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: {
theme: `${courseApi.course?.certificateTheme}`,
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
studentName: `${$profile.fullname}`,
courseName: `${courseApi.course?.title}`,
courseDescription: `${courseApi.course?.description}`,
@@ -52,22 +90,24 @@
isLoading = false;
};
const hasUserCompletedCourse = async () => {
const loadEvaluation = async () => {
if (!courseId) return;
isLoading = true;
const res = await courseApi.getCertificationEvaluation(courseId);
if (res?.data) {
evaluation = res.data;
const hasEarned = res.data.certificateEarnedAt != null;
isCourseComplete = hasEarned || !!res.data.eligibleForCertificate;
const response = await courseApi.getProgress(courseId, $profile.id);
if (courseApi.success && response?.data) {
const progress = response.data;
isCourseComplete =
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
if (res.data.isNewCompletion && courseId) {
openCourseCompletionModal(courseId);
}
}
isLoading = false;
};
onMount(() => {
hasUserCompletedCourse();
loadEvaluation();
});
let title = $derived(
@@ -75,15 +115,40 @@
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title'
);
let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle'
);
let progressLines = $derived.by(() => {
if (!evaluation) {
return [
t.get('course.certificates.progress_hint', {
current: 0,
required: courseApi.course?.certificate?.threshold ?? 100
})
];
}
const blockers = evaluation.blockers ?? [];
if (blockers.length > 0) {
return blockers.map((b) => formatBlockerMessage(b));
}
return [
t.get('course.certificates.progress_hint', {
current: evaluation.progressPercent,
required: evaluation.certificationThreshold
})
];
});
</script>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Empty title={$t(title)} icon={DownloadIcon} variant="page">
<div class="ui:text-muted-foreground mx-auto mb-4 max-w-md space-y-2 text-center text-sm">
{#if isCourseComplete}
<p>{$t('course.navItem.certificates.unlocked_certificate_subtitle')}</p>
{:else}
{#each progressLines as line (line)}
<p>{line}</p>
{/each}
{/if}
</div>
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}