feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
+134
@@ -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>
|
||||
+186
@@ -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>
|
||||
+57
@@ -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>
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{#if courseApi.course?.isCertificateDownloadable === true}
|
||||
{#if courseApi.course?.certificate?.isDownloadable === true}
|
||||
<UnlockedCertificate />
|
||||
{:else}
|
||||
<LockedCertificate />
|
||||
|
||||
+82
-17
@@ -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')}
|
||||
|
||||
Reference in New Issue
Block a user