feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
+98
-114
@@ -1,134 +1,118 @@
|
||||
<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 { Badge } from '@cio/ui/base/badge';
|
||||
import { Certificate } from '@cio/ui';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
|
||||
import ZapIcon from '@lucide/svelte/icons/zap';
|
||||
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';
|
||||
import {
|
||||
CERTIFICATE_TEMPLATES,
|
||||
DEFAULT_CERTIFICATE_DESIGN,
|
||||
type CertificateDesign,
|
||||
resolveTemplateId
|
||||
} from '@cio/certificates';
|
||||
|
||||
type Props = {
|
||||
errors: Record<string, string>;
|
||||
errors?: Record<string, string>;
|
||||
};
|
||||
|
||||
let { errors }: Props = $props();
|
||||
let { errors: _errors }: Props = $props();
|
||||
|
||||
const studentNamePlaceholder = 'Name of student';
|
||||
const design: CertificateDesign = $derived.by(() => {
|
||||
const certificate = courseApi.course?.certificate;
|
||||
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
|
||||
const legacyTheme = certificate?.theme as string | undefined;
|
||||
|
||||
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
|
||||
return {
|
||||
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
|
||||
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
|
||||
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: stored?.descriptionOverride,
|
||||
signatories: [
|
||||
{
|
||||
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
},
|
||||
{
|
||||
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
}
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
});
|
||||
|
||||
const helperText = $derived(
|
||||
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
|
||||
const previewData = $derived({
|
||||
recipientName: 'Eleanor Vance',
|
||||
courseName: courseApi.course?.title ?? 'Course Title',
|
||||
courseDescription: design.descriptionOverride || courseApi.course?.description || '',
|
||||
orgName: $currentOrg.name || 'Organization',
|
||||
orgLogoUrl: $currentOrg.avatarUrl || undefined,
|
||||
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
|
||||
certificateId: (design.idFormat ?? 'N° {seq}').replace('{seq}', '0247')
|
||||
});
|
||||
|
||||
const templateLabel = $derived(
|
||||
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === design.templateId)?.label ?? design.templateId
|
||||
);
|
||||
|
||||
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 || ''
|
||||
})
|
||||
);
|
||||
}
|
||||
const courseId = $derived(courseApi.course?.id ?? '');
|
||||
const editorHref = $derived(courseId ? resolve('/courses/[id]/certificates/editor', { id: courseId }) : '#');
|
||||
</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>
|
||||
<div class="grid w-full gap-4 lg:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<div class="aspect-[1.4/1] w-full">
|
||||
<Certificate.Preview {design} data={previewData} zoom="fit" />
|
||||
</div>
|
||||
|
||||
<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 class="flex flex-col gap-3">
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="text-base">{$t('course.navItem.certificates.editor.summary_title')}</Card.Title>
|
||||
<Card.Description>{$t('course.navItem.certificates.editor.summary_subtitle')}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-3 pb-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_template')}
|
||||
</span>
|
||||
<Badge variant="secondary" class="text-[10px] uppercase">{templateLabel}</Badge>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_accent')}
|
||||
</span>
|
||||
<span class="ui:border-border inline-flex items-center gap-2 rounded-full border px-2 py-0.5 text-xs">
|
||||
<span class="size-3.5 rounded-full" style:background-color={design.accentColor} aria-hidden="true"></span>
|
||||
{design.accentColor}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="ui:text-muted-foreground text-xs tracking-wider uppercase">
|
||||
{$t('course.navItem.certificates.editor.field_signatories')}
|
||||
</span>
|
||||
<div class="text-right text-xs">
|
||||
<div class="font-medium">{design.signatories[0].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
|
||||
<div class="mt-1 font-medium">{design.signatories[1].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Footer>
|
||||
<Button class="w-full justify-center" disabled={$isFreePlan || !courseId} href={editorHref}>
|
||||
{#if $isFreePlan}
|
||||
<ZapIcon class="size-4" />
|
||||
{/if}
|
||||
{$t('course.navItem.certificates.editor.customize_design')}
|
||||
<ArrowRightIcon class="size-4" />
|
||||
</Button>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
templateLabel: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
isFreePlan: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
class="ui:border-border ui:bg-background ui:text-foreground flex shrink-0 items-center justify-between gap-3 border-b px-3 py-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href={resolve('/courses/[id]/certificates', { id: courseId })}
|
||||
class="ui:size-8 shrink-0"
|
||||
aria-label={$t('course.navItem.certificates.editor.back')}
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
|
||||
<h1 class="truncate text-sm tracking-tight sm:text-base">
|
||||
{courseTitle}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
<Badge variant="secondary" class="text-[10px] uppercase">
|
||||
{templateLabel}
|
||||
</Badge>
|
||||
{#if isDirty}
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
{$t('course.navItem.certificates.editor.unsaved')}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if isFreePlan}
|
||||
<Badge variant="outline" class="text-[10px]">
|
||||
{$t('course.navItem.certificates.editor.free_plan')}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 items-center gap-1.5">
|
||||
{#if isDirty}
|
||||
<Button variant="ghost" size="sm" type="button" onclick={onDiscard} class="hidden sm:inline-flex">
|
||||
{$t('course.navItem.certificates.editor.discard')}
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!isDirty || isSaving}
|
||||
loading={isSaving}
|
||||
onclick={onSave}
|
||||
>
|
||||
{$t('course.navItem.certificates.editor.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
|
||||
import { Certificate } from '@cio/ui';
|
||||
import { IconButton } from '@cio/ui/custom/icon-button';
|
||||
import LayersIcon from '@lucide/svelte/icons/layers';
|
||||
import TypeIcon from '@lucide/svelte/icons/type';
|
||||
import PaletteIcon from '@lucide/svelte/icons/palette';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import CertificateEditorHeader from './certificate-editor-header.svelte';
|
||||
import TemplatesPanel from './panels/templates-panel.svelte';
|
||||
import ContentPanel from './panels/content-panel.svelte';
|
||||
import ColorsPanel from './panels/colors-panel.svelte';
|
||||
import ExportPanel from './panels/export-panel.svelte';
|
||||
import { certificateEditorStore, type CertificateEditorPanel } from './store/certificate-editor.store.svelte';
|
||||
|
||||
let { courseId }: { courseId: string } = $props();
|
||||
|
||||
const store = certificateEditorStore;
|
||||
|
||||
$effect(() => {
|
||||
if (!courseApi.course?.id || courseApi.course.id !== courseId) return;
|
||||
store.syncFromCourse(courseId);
|
||||
});
|
||||
|
||||
const courseTitle = $derived(courseApi.course?.title ?? '');
|
||||
|
||||
const activeTemplateMeta = $derived(
|
||||
CERTIFICATE_TEMPLATES.find((tpl) => tpl.id === store.draft.templateId) ?? CERTIFICATE_TEMPLATES[0]
|
||||
);
|
||||
|
||||
const previewDesign = $derived(store.toDesign());
|
||||
|
||||
const sampleRenderData = $derived({
|
||||
recipientName: $profile.fullname || $t('course.navItem.certificates.editor.sample_recipient'),
|
||||
courseName: courseTitle || $t('course.navItem.certificates.editor.sample_course'),
|
||||
courseDescription:
|
||||
store.draft.descriptionOverride ||
|
||||
courseApi.course?.description ||
|
||||
$t('course.navItem.certificates.editor.sample_description'),
|
||||
orgName: $currentOrg.name || $t('course.navItem.certificates.editor.sample_org'),
|
||||
orgLogoUrl: $currentOrg.avatarUrl || undefined,
|
||||
date: new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: '2-digit' }),
|
||||
certificateId: (store.draft.idFormat || 'N° {seq}').replace('{seq}', '0247')
|
||||
});
|
||||
|
||||
function navVariant(panel: CertificateEditorPanel) {
|
||||
return store.activePanel === panel ? ('default' as const) : ('ghost' as const);
|
||||
}
|
||||
|
||||
function setActive(panel: CertificateEditorPanel) {
|
||||
store.activePanel = panel;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if courseApi.course?.id === courseId}
|
||||
<div class="ui:bg-background ui:text-foreground flex h-dvh flex-col">
|
||||
<CertificateEditorHeader
|
||||
{courseId}
|
||||
{courseTitle}
|
||||
templateLabel={activeTemplateMeta.label}
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
isFreePlan={$isFreePlan}
|
||||
onSave={() => store.save()}
|
||||
onDiscard={() => store.reset()}
|
||||
/>
|
||||
|
||||
<div class="flex min-h-0 flex-1">
|
||||
<nav
|
||||
class="ui:border-border ui:bg-secondary flex w-14 shrink-0 flex-col items-center gap-1.5 border-r py-3"
|
||||
aria-label={$t('course.navItem.certificates.editor.sections')}
|
||||
>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('templates')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_templates')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_templates')}
|
||||
aria-current={store.activePanel === 'templates' ? 'page' : undefined}
|
||||
onclick={() => setActive('templates')}
|
||||
>
|
||||
<LayersIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('content')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_content')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_content')}
|
||||
aria-current={store.activePanel === 'content' ? 'page' : undefined}
|
||||
onclick={() => setActive('content')}
|
||||
>
|
||||
<TypeIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('colors')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_colors')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_colors')}
|
||||
aria-current={store.activePanel === 'colors' ? 'page' : undefined}
|
||||
onclick={() => setActive('colors')}
|
||||
>
|
||||
<PaletteIcon class="size-4" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant={navVariant('export')}
|
||||
tooltip={$t('course.navItem.certificates.editor.panel_export')}
|
||||
tooltipSide="right"
|
||||
aria-label={$t('course.navItem.certificates.editor.panel_export')}
|
||||
aria-current={store.activePanel === 'export' ? 'page' : undefined}
|
||||
onclick={() => setActive('export')}
|
||||
>
|
||||
<DownloadIcon class="size-4" />
|
||||
</IconButton>
|
||||
</nav>
|
||||
|
||||
<aside class="ui:border-border ui:bg-card flex w-[min(100%,380px)] shrink-0 flex-col border-r">
|
||||
<div class="ui:border-border border-b px-5 py-4">
|
||||
{#if store.activePanel === 'templates'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_templates')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_templates_subtitle')}
|
||||
</p>
|
||||
{:else if store.activePanel === 'content'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_content')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_content_subtitle')}
|
||||
</p>
|
||||
{:else if store.activePanel === 'colors'}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_colors')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_colors_subtitle')}
|
||||
</p>
|
||||
{:else}
|
||||
<h2 class="text-sm font-semibold">{$t('course.navItem.certificates.editor.panel_export')}</h2>
|
||||
<p class="ui:text-muted-foreground mt-1 text-xs">
|
||||
{$t('course.navItem.certificates.editor.panel_export_subtitle')}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{#if store.activePanel === 'templates'}
|
||||
<TemplatesPanel
|
||||
value={store.draft.templateId}
|
||||
disabled={$isFreePlan}
|
||||
onSelect={(id: CertificateTemplateId) => store.setTemplate(id)}
|
||||
/>
|
||||
{:else if store.activePanel === 'content'}
|
||||
<ContentPanel disabled={$isFreePlan} />
|
||||
{:else if store.activePanel === 'colors'}
|
||||
<ColorsPanel
|
||||
value={store.draft.accentColor}
|
||||
disabled={$isFreePlan}
|
||||
onSelect={(color) => store.setAccent(color)}
|
||||
/>
|
||||
{:else}
|
||||
<ExportPanel {courseId} {courseTitle} disabled={$isFreePlan} />
|
||||
{/if}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
class="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-auto bg-zinc-100 bg-[radial-gradient(circle,#d4d4d8_1px,transparent_1px)] [background-size:18px_18px] dark:bg-zinc-950 dark:bg-[radial-gradient(circle,rgba(113,113,122,0.4)_1px,transparent_1px)]"
|
||||
aria-label={$t('course.navItem.certificates.editor.preview')}
|
||||
>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center p-6 sm:p-10 lg:p-14">
|
||||
<Certificate.Preview design={previewDesign} data={sampleRenderData} showControls />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { ACCENT_COLORS } from '@cio/certificates';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onSelect: (color: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onSelect, disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-6 gap-2">
|
||||
{#each ACCENT_COLORS as color (color)}
|
||||
{@const isActive = color.toLowerCase() === value.toLowerCase()}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'ui:border-border relative aspect-square rounded-md border transition-transform',
|
||||
'hover:-translate-y-0.5',
|
||||
isActive && 'ui:ring-foreground ring-2 ring-offset-2',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0'
|
||||
)}
|
||||
style:background-color={color}
|
||||
aria-label={color}
|
||||
aria-pressed={isActive}
|
||||
{disabled}
|
||||
onclick={() => onSelect(color)}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="ui:text-muted-foreground text-xs">
|
||||
{$t('course.navItem.certificates.editor.accent_hint')}
|
||||
</p>
|
||||
</div>
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
|
||||
|
||||
interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Field.Group>
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_header')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.subtitle')}
|
||||
bind:value={certificateEditorStore.draft.subtitle}
|
||||
placeholder={$t('course.navItem.certificates.editor.subtitle_placeholder')}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<TextareaField
|
||||
label={$t('course.navItem.certificates.editor.description_override')}
|
||||
rows={4}
|
||||
bind:value={certificateEditorStore.draft.descriptionOverride}
|
||||
placeholder={$t('course.navItem.certificates.editor.description_override_placeholder')}
|
||||
{disabled}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.description_override_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_signatories')}</Field.Legend>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_one_name')}
|
||||
bind:value={certificateEditorStore.draft.signatories[0].name}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_one_role')}
|
||||
bind:value={certificateEditorStore.draft.signatories[0].role}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_two_name')}
|
||||
bind:value={certificateEditorStore.draft.signatories[1].name}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.signatory_two_role')}
|
||||
bind:value={certificateEditorStore.draft.signatories[1].role}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.certificates.editor.section_reference')}</Field.Legend>
|
||||
<Field.Field>
|
||||
<InputField
|
||||
label={$t('course.navItem.certificates.editor.id_format')}
|
||||
bind:value={certificateEditorStore.draft.idFormat}
|
||||
placeholder={'N° {seq}'}
|
||||
isDisabled={disabled}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.id_format_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
import PrinterIcon from '@lucide/svelte/icons/printer';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
courseTitle: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { courseId, courseTitle, disabled = false }: Props = $props();
|
||||
|
||||
let isPdfLoading = $state(false);
|
||||
let isPngLoading = $state(false);
|
||||
let isPrintLoading = $state(false);
|
||||
|
||||
const downloadName = $derived((courseTitle || 'certificate').replace(/\s+/g, '-').toLowerCase());
|
||||
|
||||
async function buildBody() {
|
||||
return {
|
||||
studentName: $profile.fullname || 'Preview Recipient',
|
||||
studentId: $profile.id || undefined,
|
||||
issuedAt: new Date().toISOString(),
|
||||
previewMode: true
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
if (disabled || isPdfLoading) return;
|
||||
isPdfLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
triggerDownload(new Blob([blob], { type: 'application/pdf' }), `${downloadName}.pdf`);
|
||||
} catch (error) {
|
||||
console.error('Preview PDF error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPdfLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPng() {
|
||||
if (disabled || isPngLoading) return;
|
||||
isPngLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
triggerDownload(new Blob([blob], { type: 'image/png' }), `${downloadName}.png`);
|
||||
} catch (error) {
|
||||
console.error('Preview PNG error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPngLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function print() {
|
||||
if (disabled || isPrintLoading) return;
|
||||
isPrintLoading = true;
|
||||
|
||||
try {
|
||||
const body = await buildBody();
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: body
|
||||
});
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(
|
||||
`<!doctype html><html><head><title>${courseTitle}</title>` +
|
||||
`<style>@page{size:A4 landscape;margin:0}body{margin:0;display:flex;align-items:center;justify-content:center;background:#fff}img{width:100vw;max-width:1100px;height:auto}
|
||||
</style>` +
|
||||
`</head><body><img alt="" src="${url}" onload="setTimeout(()=>window.print(),300)"></body></html>`
|
||||
);
|
||||
printWindow.document.close();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview print error', error);
|
||||
snackbar.error('course.navItem.certificates.editor.preview_failed');
|
||||
} finally {
|
||||
isPrintLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Button class="w-full justify-start" {disabled} loading={isPdfLoading} onclick={downloadPdf}>
|
||||
<DownloadIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.download_pdf')}
|
||||
</Button>
|
||||
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPngLoading} onclick={downloadPng}>
|
||||
<ImageIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.download_png')}
|
||||
</Button>
|
||||
<Button variant="outline" class="w-full justify-start" {disabled} loading={isPrintLoading} onclick={print}>
|
||||
<PrinterIcon class="size-4" />
|
||||
{$t('course.navItem.certificates.editor.print')}
|
||||
</Button>
|
||||
|
||||
<p class="ui:text-muted-foreground mt-3 text-xs">
|
||||
{$t('course.navItem.certificates.editor.export_hint')}
|
||||
</p>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { CertificateTemplateId } from '@cio/certificates';
|
||||
|
||||
interface Props {
|
||||
id: CertificateTemplateId;
|
||||
}
|
||||
|
||||
let { id }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if id === 'classique'}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-[#faf6ec] text-[#6b4513]">
|
||||
<span class="text-[18px] italic" style="font-family:'Bodoni Moda',serif">Classique</span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">— EST 1892 —</span>
|
||||
</div>
|
||||
{:else if id === 'brutalist'}
|
||||
<div class="flex h-full w-full items-end justify-start bg-[#f0ede4] p-2">
|
||||
<div
|
||||
class="w-full text-left leading-[0.85] text-black"
|
||||
style="font-family:'Archivo Black',sans-serif;font-size:18px"
|
||||
>
|
||||
RAW<br />
|
||||
<span class="text-[#ff4500] italic" style="font-family:'Playfair Display',serif;font-weight:400">form</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else if id === 'noir'}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-[#0e0e0e] text-[#d4af37]">
|
||||
<span class="text-[16px] text-[#f5f1e8] italic" style="font-family:'Playfair Display',serif">Noir</span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'Cinzel',serif">★ ATELIER ★</span>
|
||||
</div>
|
||||
{:else if id === 'poster'}
|
||||
<div class="relative h-full w-full overflow-hidden bg-[#fef2dc]">
|
||||
<span class="absolute -top-3 -right-4 h-10 w-10 rounded-full bg-[#ff5722]"></span>
|
||||
<span class="absolute -bottom-3 -left-3 h-9 w-9 rounded-full bg-[#1e3a8a]"></span>
|
||||
<div class="relative z-10 flex h-full w-full items-center justify-center">
|
||||
<span class="font-black italic" style="font-family:'Playfair Display',serif;font-size:18px">Poster</span>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex h-full w-full flex-col items-center justify-center bg-white text-black">
|
||||
<span class="text-[18px] font-light italic" style="font-family:'Cormorant Garamond',serif">Minimal</span>
|
||||
<span class="mt-1 block h-px w-7 bg-black"></span>
|
||||
<span class="mt-1 text-[6px] tracking-[0.3em]" style="font-family:'JetBrains Mono',monospace">N°001</span>
|
||||
</div>
|
||||
{/if}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { CERTIFICATE_TEMPLATES, type CertificateTemplateId } from '@cio/certificates';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { cn } from '@cio/ui/tools';
|
||||
import TemplateThumb from './template-thumb.svelte';
|
||||
|
||||
interface Props {
|
||||
value: CertificateTemplateId;
|
||||
onSelect: (id: CertificateTemplateId) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let { value, onSelect, disabled = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each CERTIFICATE_TEMPLATES as template (template.id)}
|
||||
{@const isActive = template.id === value}
|
||||
<button
|
||||
type="button"
|
||||
class={cn(
|
||||
'group ui:border-border relative aspect-[1.4/1] overflow-hidden rounded-md border bg-white text-left transition-transform',
|
||||
'hover:-translate-y-0.5 hover:shadow-md',
|
||||
isActive && 'ui:border-primary ui:ring-primary -translate-y-0.5 shadow-md ring-2',
|
||||
disabled && 'cursor-not-allowed opacity-60 hover:translate-y-0 hover:shadow-none'
|
||||
)}
|
||||
{disabled}
|
||||
aria-pressed={isActive}
|
||||
onclick={() => onSelect(template.id)}
|
||||
>
|
||||
<TemplateThumb id={template.id} />
|
||||
<span
|
||||
class={cn(
|
||||
'absolute right-0 bottom-0 left-0 px-2 py-1 text-center text-[9px] font-medium tracking-[0.18em] uppercase',
|
||||
isActive ? 'ui:bg-primary ui:text-primary-foreground' : 'ui:bg-foreground ui:text-background'
|
||||
)}
|
||||
>
|
||||
{template.label}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<p class="ui:text-muted-foreground mt-4 text-xs">
|
||||
{$t('course.navItem.certificates.editor.templates_hint')}
|
||||
</p>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
import {
|
||||
DEFAULT_CERTIFICATE_DESIGN,
|
||||
resolveTemplateId,
|
||||
type CertificateDesign,
|
||||
type CertificateTemplateId
|
||||
} from '@cio/certificates';
|
||||
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
export type CertificateEditorPanel = 'templates' | 'content' | 'colors' | 'export';
|
||||
|
||||
/**
|
||||
* The store keeps optional fields as concrete strings so two-way bindings to
|
||||
* inputs are simple — we collapse empty strings to `undefined` only when
|
||||
* shipping a payload back to the API.
|
||||
*/
|
||||
export interface CertificateEditorDraft {
|
||||
templateId: CertificateTemplateId;
|
||||
accentColor: string;
|
||||
subtitle: string;
|
||||
descriptionOverride: string;
|
||||
idFormat: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
}
|
||||
|
||||
function toDraft(design: CertificateDesign): CertificateEditorDraft {
|
||||
return {
|
||||
templateId: design.templateId,
|
||||
accentColor: design.accentColor,
|
||||
subtitle: design.subtitle ?? '',
|
||||
descriptionOverride: design.descriptionOverride ?? '',
|
||||
idFormat: design.idFormat ?? '',
|
||||
signatories: [
|
||||
{ name: design.signatories[0]?.name ?? '', role: design.signatories[0]?.role ?? '' },
|
||||
{ name: design.signatories[1]?.name ?? '', role: design.signatories[1]?.role ?? '' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function fromDraft(draft: CertificateEditorDraft): CertificateDesign {
|
||||
return {
|
||||
templateId: draft.templateId,
|
||||
accentColor: draft.accentColor,
|
||||
subtitle: draft.subtitle.trim() || undefined,
|
||||
descriptionOverride: draft.descriptionOverride.trim() || undefined,
|
||||
idFormat: draft.idFormat.trim() || undefined,
|
||||
signatories: [
|
||||
{ name: draft.signatories[0].name, role: draft.signatories[0].role },
|
||||
{ name: draft.signatories[1].name, role: draft.signatories[1].role }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
function readStoredDesign(): CertificateDesign {
|
||||
const certificate = courseApi.course?.certificate;
|
||||
const stored = certificate?.design as Partial<CertificateDesign> | undefined;
|
||||
const legacyTheme = certificate?.theme;
|
||||
|
||||
return {
|
||||
templateId: resolveTemplateId(stored?.templateId ?? legacyTheme),
|
||||
accentColor: stored?.accentColor ?? DEFAULT_CERTIFICATE_DESIGN.accentColor,
|
||||
subtitle: stored?.subtitle ?? DEFAULT_CERTIFICATE_DESIGN.subtitle,
|
||||
descriptionOverride: stored?.descriptionOverride,
|
||||
signatories: [
|
||||
{
|
||||
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
},
|
||||
{
|
||||
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
}
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
}
|
||||
|
||||
class CertificateEditorStore {
|
||||
activePanel = $state<CertificateEditorPanel>('templates');
|
||||
draft = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
initial = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
isSaving = $state(false);
|
||||
#initializedCourseId: string | null = null;
|
||||
|
||||
readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial));
|
||||
|
||||
syncFromCourse(courseId: string, force = false) {
|
||||
if (!force && this.#initializedCourseId === courseId) return;
|
||||
|
||||
const stored = readStoredDesign();
|
||||
this.initial = toDraft(stored);
|
||||
this.draft = toDraft(stored);
|
||||
this.#initializedCourseId = courseId;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.draft = toDraft(fromDraft(this.initial));
|
||||
}
|
||||
|
||||
setTemplate(templateId: CertificateTemplateId) {
|
||||
this.draft.templateId = templateId;
|
||||
}
|
||||
|
||||
setAccent(color: string) {
|
||||
this.draft.accentColor = color;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a render-ready design with empty optional strings collapsed to
|
||||
* `undefined`, suitable for handing to `Certificate.Preview` / API payload.
|
||||
*/
|
||||
toDesign(): CertificateDesign {
|
||||
return fromDraft(this.draft);
|
||||
}
|
||||
|
||||
async save() {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) return;
|
||||
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const certificate = {
|
||||
...(course.certificate ?? {}),
|
||||
design: fromDraft(this.draft),
|
||||
theme: this.draft.templateId
|
||||
};
|
||||
|
||||
const updated = await courseApi.update(course.id, { certificate }, { showSuccessToast: false });
|
||||
|
||||
if (updated) {
|
||||
this.initial = {
|
||||
...this.draft,
|
||||
signatories: [{ ...this.draft.signatories[0] }, { ...this.draft.signatories[1] }]
|
||||
};
|
||||
snackbar.success(t.get('course.navItem.certificates.editor.saved'));
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const certificateEditorStore = new CertificateEditorStore();
|
||||
+58
-24
@@ -1,8 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import DownloadIcon from '@lucide/svelte/icons/download';
|
||||
import ImageIcon from '@lucide/svelte/icons/image';
|
||||
|
||||
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';
|
||||
@@ -14,6 +14,7 @@
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
let isLoading = $state(false);
|
||||
let isPngLoading = $state(false);
|
||||
let isCourseComplete = $state(false);
|
||||
let evaluation = $state<CertificationEvaluationData | null>(null);
|
||||
|
||||
@@ -54,6 +55,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
function buildBody() {
|
||||
return {
|
||||
studentName: $profile.fullname || 'Recipient',
|
||||
studentId: $profile.id || undefined,
|
||||
issuedAt: new Date().toISOString()
|
||||
} as const;
|
||||
}
|
||||
|
||||
async function triggerSave(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
document.body.append(link);
|
||||
link.download = filename;
|
||||
link.href = url;
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const downLoadCertificate = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
@@ -61,27 +81,13 @@
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
|
||||
param: { courseId },
|
||||
json: {
|
||||
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
|
||||
studentName: `${$profile.fullname}`,
|
||||
courseName: `${courseApi.course?.title}`,
|
||||
courseDescription: `${courseApi.course?.description}`,
|
||||
orgLogoUrl: $currentOrg.avatarUrl || `${$currentOrgDomain}/logo-512.png`,
|
||||
orgName: `${$currentOrg.name}`
|
||||
}
|
||||
json: buildBody()
|
||||
});
|
||||
|
||||
const blobResponse = await response.blob();
|
||||
|
||||
const file = new Blob([blobResponse], { 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();
|
||||
await triggerSave(
|
||||
new Blob([blobResponse], { type: 'application/pdf' }),
|
||||
`certificate-${courseApi.course?.title ?? 'course'}.pdf`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
@@ -90,6 +96,28 @@
|
||||
isLoading = false;
|
||||
};
|
||||
|
||||
const downloadCertificateImage = async () => {
|
||||
if (!isCourseComplete || !courseId) return;
|
||||
|
||||
isPngLoading = true;
|
||||
try {
|
||||
const response = await classroomio.course[':courseId']['download']['certificate']['png']['$post']({
|
||||
param: { courseId },
|
||||
json: buildBody()
|
||||
});
|
||||
const blobResponse = await response.blob();
|
||||
await triggerSave(
|
||||
new Blob([blobResponse], { type: 'image/png' }),
|
||||
`certificate-${courseApi.course?.title ?? 'course'}.png`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
}
|
||||
|
||||
isPngLoading = false;
|
||||
};
|
||||
|
||||
const loadEvaluation = async () => {
|
||||
if (!courseId) return;
|
||||
isLoading = true;
|
||||
@@ -149,9 +177,15 @@
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
<div class="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
|
||||
<DownloadIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_certificate')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={downloadCertificateImage} disabled={!isCourseComplete} loading={isPngLoading}>
|
||||
<ImageIcon size={16} />
|
||||
{$t('course.navItem.certificates.download_image')}
|
||||
</Button>
|
||||
</div>
|
||||
</Empty>
|
||||
</div>
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/blue-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/blue-badge-pattern-badge.svg" alt="A badge" class="w-32 max-w-none md:w-40" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#2D499F] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#2D499F]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/blue-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,53 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-4';
|
||||
const bordercolor = 'border-pink-700';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg flex w-full flex-col items-center justify-center {borderwidth} {bordercolor} py-3">
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class=" h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 w-full text-center text-sm font-normal tracking-widest text-gray-500 uppercase">
|
||||
Certificate of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed the course</p>
|
||||
<div>
|
||||
<p class="clamp bg-transparent text-center text-base font-semibold text-pink-700 uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc text-center dark:text-black">{courseApi.course?.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const borderwidth = 'border-l-8';
|
||||
const bordercolor = 'border-primary-800';
|
||||
const logo = '/logo-512.png';
|
||||
</script>
|
||||
|
||||
<div class="certificate-bg border-primary-400 flex w-full items-center justify-center border border-dashed">
|
||||
<div class="{borderwidth} {bordercolor} w-full">
|
||||
<div class="w-full px-5 py-3">
|
||||
<p class="my-2 text-sm font-normal text-gray-500 uppercase">
|
||||
Certificate<br /> of completion
|
||||
</p>
|
||||
<div class="mb-4 border-b border-gray-500">
|
||||
<p class="text-xs font-medium text-black">This is to certify that</p>
|
||||
<p class="student-name text-center text-5xl dark:text-black">{studentName}</p>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<p class="text-xs font-medium text-black">has successfully completed introductory courses in</p>
|
||||
<div>
|
||||
<p class="clamp text-primary-800 my-2 bg-transparent text-center text-base font-semibold uppercase">
|
||||
{courseApi.course?.title}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc mb-4 text-justify dark:text-black">{courseApi.course?.description}</p>
|
||||
<div class="my-2 flex items-center gap-1">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="h-10 w-10 rounded-md" />
|
||||
<p class="font-semibold capitalize dark:text-black">{$currentOrg.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.student-name {
|
||||
font-family: 'Qwitcher Grypen', cursive;
|
||||
}
|
||||
.desc {
|
||||
font-size: 10px;
|
||||
}
|
||||
.clamp {
|
||||
font-size: clamp(14px, 3vw, 16px);
|
||||
}
|
||||
.certificate-bg {
|
||||
background-color: #f5f8fe;
|
||||
}
|
||||
</style>
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="relative w-full dark:text-black">
|
||||
<img src="/images/purple-badge-pattern-background.svg" alt="" class="absolute z-0 h-full w-full object-cover" />
|
||||
<div class="relative z-20 m-2 flex h-full justify-between bg-white md:h-68">
|
||||
<div class="w-[70%] pl-3">
|
||||
<header class="absolute flex w-full items-center justify-center gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-16">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#914397]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has successfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="my-8 flex items-center justify-between text-center">
|
||||
<div>
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-center overflow-hidden">
|
||||
<img src="/images/purple-badge-pattern-badge.svg" alt="A badge" class="w-[8rem] max-w-none md:w-[10rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
|
||||
interface Props {
|
||||
studentName?: string;
|
||||
}
|
||||
|
||||
let { studentName = '' }: Props = $props();
|
||||
const logo = '/logo-512.png';
|
||||
|
||||
const dateIssued = new Date().toLocaleDateString();
|
||||
</script>
|
||||
|
||||
<div class="w-full bg-[#95449A] p-3 dark:text-black">
|
||||
<div class="flex w-full bg-white pb-5">
|
||||
<div class="w-[80%] pl-3">
|
||||
<header class="flex items-center justify-start gap-1 text-sm">
|
||||
<img src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : logo} alt="logo" class="w-5" />
|
||||
<h1 class="text-sm">{$currentOrg.name}</h1>
|
||||
</header>
|
||||
|
||||
<div class="mt-7">
|
||||
<h2 class="m-0 text-[7px]">This certificate is awarded to</h2>
|
||||
<h1 class="m-0 mt-0.5 text-lg leading-3 text-[#95449A]">{studentName}</h1>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 w-[90%]">
|
||||
<h1 class="text-[7px]">has succesfully completed training on</h1>
|
||||
<h2 class="m-0 text-lg">
|
||||
{courseApi.course?.title}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 mb-5 flex items-center justify-between text-center">
|
||||
<div class="">
|
||||
<h1 class="m-0 h-2/4 text-[7px]">DATE ISSUED:</h1>
|
||||
<p class="border-b border-black text-[7px]">{dateIssued}</p>
|
||||
</div>
|
||||
<div class="w-[20%] text-[7px]">
|
||||
<div class=""></div>
|
||||
<p class="border-t border-black text-[7px]">Signature</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="desc w-[90%] text-[8px]">
|
||||
{courseApi.course?.description}
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<div class="w-[20%]">
|
||||
<img src="/images/purple-professional-badge.svg" alt="A badge" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user