feat: add sequential progression and video watch lesson completion
Gate student content by completion order, track per-video watch progress with seek enforcement, and auto-complete lessons at the configured threshold. Show ring progress in the lesson header, keep watch progress on rewatch, and sync progression locks client-side so the next item unlocks after completion.
This commit is contained in:
+14
-6
@@ -36,11 +36,15 @@
|
||||
signatories: [
|
||||
{
|
||||
name: stored?.signatories?.[0]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].name,
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role
|
||||
role: stored?.signatories?.[0]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].role,
|
||||
enabled: stored?.signatories?.[0]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[0].enabled,
|
||||
signatureUrl: stored?.signatories?.[0]?.signatureUrl
|
||||
},
|
||||
{
|
||||
name: stored?.signatories?.[1]?.name ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].name,
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role
|
||||
role: stored?.signatories?.[1]?.role ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].role,
|
||||
enabled: stored?.signatories?.[1]?.enabled ?? DEFAULT_CERTIFICATE_DESIGN.signatories[1].enabled,
|
||||
signatureUrl: stored?.signatories?.[1]?.signatureUrl
|
||||
}
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
@@ -97,10 +101,14 @@
|
||||
{$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>
|
||||
{#if design.signatories[0].enabled}
|
||||
<div class="font-medium">{design.signatories[0].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[0].role}</div>
|
||||
{/if}
|
||||
{#if design.signatories[1].enabled}
|
||||
<div class:mt-1={design.signatories[0].enabled} class="font-medium">{design.signatories[1].name}</div>
|
||||
<div class="ui:text-muted-foreground">{design.signatories[1].role}</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
|
||||
+13
-2
@@ -11,12 +11,23 @@
|
||||
templateLabel: string;
|
||||
isDirty: boolean;
|
||||
isSaving: boolean;
|
||||
isSignatureUploading?: boolean;
|
||||
isFreePlan: boolean;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
}
|
||||
|
||||
let { courseId, courseTitle, templateLabel, isDirty, isSaving, isFreePlan, onSave, onDiscard }: Props = $props();
|
||||
let {
|
||||
courseId,
|
||||
courseTitle,
|
||||
templateLabel,
|
||||
isDirty,
|
||||
isSaving,
|
||||
isSignatureUploading = false,
|
||||
isFreePlan,
|
||||
onSave,
|
||||
onDiscard
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<header
|
||||
@@ -66,7 +77,7 @@
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={!isDirty || isSaving}
|
||||
disabled={!isDirty || isSaving || isSignatureUploading}
|
||||
loading={isSaving}
|
||||
onclick={onSave}
|
||||
>
|
||||
|
||||
+1
@@ -66,6 +66,7 @@
|
||||
templateLabel={activeTemplateMeta.label}
|
||||
isDirty={store.isDirty}
|
||||
isSaving={store.isSaving}
|
||||
isSignatureUploading={store.isSignatureUploading}
|
||||
isFreePlan={$isFreePlan}
|
||||
onSave={() => store.save()}
|
||||
onDiscard={() => store.reset()}
|
||||
|
||||
+154
-28
@@ -1,8 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { UploadImage } from '$features/ui';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { uploadImage } from '$lib/utils/services/upload';
|
||||
import { certificateEditorStore } from '../store/certificate-editor.store.svelte';
|
||||
|
||||
interface Props {
|
||||
@@ -10,6 +14,73 @@
|
||||
}
|
||||
|
||||
let { disabled = false }: Props = $props();
|
||||
|
||||
let signatoryOneAvatar: File | undefined = $state();
|
||||
let signatoryTwoAvatar: File | undefined = $state();
|
||||
let signatoryOnePreview = $state('');
|
||||
let signatoryTwoPreview = $state('');
|
||||
let isSignatoryOneUploading = $state(false);
|
||||
let isSignatoryTwoUploading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
certificateEditorStore.isSignatureUploading = isSignatoryOneUploading || isSignatoryTwoUploading;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSignatoryOneUploading) return;
|
||||
|
||||
signatoryOnePreview = certificateEditorStore.draft.signatories[0].signatureUrl;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isSignatoryTwoUploading) return;
|
||||
|
||||
signatoryTwoPreview = certificateEditorStore.draft.signatories[1].signatureUrl;
|
||||
});
|
||||
|
||||
async function uploadSignatorySignature(index: 0 | 1, avatar: File) {
|
||||
if (index === 0) {
|
||||
isSignatoryOneUploading = true;
|
||||
} else {
|
||||
isSignatoryTwoUploading = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const signatureUrl = await uploadImage(avatar);
|
||||
certificateEditorStore.setSignatorySignatureUrl(index, signatureUrl);
|
||||
|
||||
if (index === 0) {
|
||||
signatoryOnePreview = signatureUrl;
|
||||
signatoryOneAvatar = undefined;
|
||||
} else {
|
||||
signatoryTwoPreview = signatureUrl;
|
||||
signatoryTwoAvatar = undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error uploading signature image:', error);
|
||||
snackbar.error('snackbar.landing_page_settings.error.try_again');
|
||||
} finally {
|
||||
if (index === 0) {
|
||||
isSignatoryOneUploading = false;
|
||||
} else {
|
||||
isSignatoryTwoUploading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const avatar = signatoryOneAvatar;
|
||||
if (!avatar || isSignatoryOneUploading) return;
|
||||
|
||||
void uploadSignatorySignature(0, avatar);
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const avatar = signatoryTwoAvatar;
|
||||
if (!avatar || isSignatoryTwoUploading) return;
|
||||
|
||||
void uploadSignatorySignature(1, avatar);
|
||||
});
|
||||
</script>
|
||||
|
||||
<Field.Group>
|
||||
@@ -44,34 +115,89 @@
|
||||
<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.Set>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={certificateEditorStore.draft.signatories[0].enabled} {disabled} />
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signatory_one_toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if certificateEditorStore.draft.signatories[0].enabled}
|
||||
<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>
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
|
||||
<UploadImage
|
||||
bind:avatar={signatoryOneAvatar}
|
||||
bind:src={signatoryOnePreview}
|
||||
shape="rounded"
|
||||
widthHeight="h-[56px] w-[120px]"
|
||||
previewVariant="signature"
|
||||
isDisabled={disabled}
|
||||
bind:isUploading={isSignatoryOneUploading}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.signature_upload_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Switch bind:checked={certificateEditorStore.draft.signatories[1].enabled} {disabled} />
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signatory_two_toggle')}</Field.Label>
|
||||
</Field.Field>
|
||||
|
||||
{#if certificateEditorStore.draft.signatories[1].enabled}
|
||||
<Field.Group>
|
||||
<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.Field>
|
||||
<Field.Label>{$t('course.navItem.certificates.editor.signature_upload')}</Field.Label>
|
||||
<UploadImage
|
||||
bind:avatar={signatoryTwoAvatar}
|
||||
bind:src={signatoryTwoPreview}
|
||||
shape="rounded"
|
||||
widthHeight="h-[56px] w-[120px]"
|
||||
previewVariant="signature"
|
||||
isDisabled={disabled}
|
||||
bind:isUploading={isSignatoryTwoUploading}
|
||||
/>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.certificates.editor.signature_upload_hint')}
|
||||
</Field.Description>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
|
||||
|
||||
+74
-20
@@ -22,7 +22,40 @@ export interface CertificateEditorDraft {
|
||||
subtitle: string;
|
||||
descriptionOverride: string;
|
||||
idFormat: string;
|
||||
signatories: [{ name: string; role: string }, { name: string; role: string }];
|
||||
signatories: [
|
||||
{ name: string; role: string; enabled: boolean; signatureUrl: string },
|
||||
{ name: string; role: string; enabled: boolean; signatureUrl: string }
|
||||
];
|
||||
}
|
||||
|
||||
function toDraftSignatory(
|
||||
signatory: CertificateDesign['signatories'][number] | undefined,
|
||||
fallback: CertificateDesign['signatories'][number]
|
||||
) {
|
||||
return {
|
||||
name: signatory?.name ?? fallback.name,
|
||||
role: signatory?.role ?? fallback.role,
|
||||
enabled: signatory?.enabled ?? fallback.enabled ?? true,
|
||||
signatureUrl: signatory?.signatureUrl ?? ''
|
||||
};
|
||||
}
|
||||
|
||||
function isPersistableSignatureUrl(url: string): boolean {
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed) return false;
|
||||
|
||||
return !trimmed.startsWith('blob:') && !trimmed.startsWith('data:');
|
||||
}
|
||||
|
||||
function fromDraftSignatory(signatory: CertificateEditorDraft['signatories'][number]) {
|
||||
const signatureUrl = signatory.signatureUrl.trim();
|
||||
|
||||
return {
|
||||
name: signatory.name,
|
||||
role: signatory.role,
|
||||
enabled: signatory.enabled,
|
||||
signatureUrl: isPersistableSignatureUrl(signatureUrl) ? signatureUrl : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function toDraft(design: CertificateDesign): CertificateEditorDraft {
|
||||
@@ -33,8 +66,8 @@ function toDraft(design: CertificateDesign): CertificateEditorDraft {
|
||||
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 ?? '' }
|
||||
toDraftSignatory(design.signatories[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]),
|
||||
toDraftSignatory(design.signatories[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1])
|
||||
]
|
||||
};
|
||||
}
|
||||
@@ -46,10 +79,7 @@ function fromDraft(draft: CertificateEditorDraft): CertificateDesign {
|
||||
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 }
|
||||
]
|
||||
signatories: [fromDraftSignatory(draft.signatories[0]), fromDraftSignatory(draft.signatories[1])]
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,14 +94,8 @@ function readStoredDesign(): CertificateDesign {
|
||||
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
|
||||
}
|
||||
toDraftSignatory(stored?.signatories?.[0], DEFAULT_CERTIFICATE_DESIGN.signatories[0]),
|
||||
toDraftSignatory(stored?.signatories?.[1], DEFAULT_CERTIFICATE_DESIGN.signatories[1])
|
||||
],
|
||||
idFormat: stored?.idFormat ?? DEFAULT_CERTIFICATE_DESIGN.idFormat
|
||||
};
|
||||
@@ -82,6 +106,7 @@ class CertificateEditorStore {
|
||||
draft = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
initial = $state<CertificateEditorDraft>(toDraft(DEFAULT_CERTIFICATE_DESIGN));
|
||||
isSaving = $state(false);
|
||||
isSignatureUploading = $state(false);
|
||||
#initializedCourseId: string | null = null;
|
||||
|
||||
readonly isDirty = $derived(JSON.stringify(this.draft) !== JSON.stringify(this.initial));
|
||||
@@ -107,6 +132,17 @@ class CertificateEditorStore {
|
||||
this.draft.accentColor = color;
|
||||
}
|
||||
|
||||
setSignatorySignatureUrl(index: 0 | 1, signatureUrl: string) {
|
||||
const signatory = this.draft.signatories[index];
|
||||
const nextSignatories = [...this.draft.signatories] as CertificateEditorDraft['signatories'];
|
||||
nextSignatories[index] = { ...signatory, signatureUrl };
|
||||
|
||||
this.draft = {
|
||||
...this.draft,
|
||||
signatories: nextSignatories
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a render-ready design with empty optional strings collapsed to
|
||||
* `undefined`, suitable for handing to `Certificate.Preview` / API payload.
|
||||
@@ -119,22 +155,40 @@ class CertificateEditorStore {
|
||||
const course = courseApi.course;
|
||||
if (!course?.id) return;
|
||||
|
||||
if (this.isSignatureUploading) {
|
||||
snackbar.error(t.get('course.navItem.certificates.editor.signature_upload_in_progress'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
try {
|
||||
const design = fromDraft(this.draft);
|
||||
const certificate = {
|
||||
...(course.certificate ?? {}),
|
||||
design: fromDraft(this.draft),
|
||||
design,
|
||||
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] }]
|
||||
};
|
||||
if (courseApi.course) {
|
||||
courseApi.course.certificate = {
|
||||
...(courseApi.course.certificate ?? {}),
|
||||
design,
|
||||
theme: this.draft.templateId
|
||||
};
|
||||
}
|
||||
|
||||
this.initial = toDraft(design);
|
||||
this.#initializedCourseId = null;
|
||||
this.syncFromCourse(course.id, true);
|
||||
snackbar.success(t.get('course.navItem.certificates.editor.saved'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.keys(courseApi.errors).length > 0) {
|
||||
snackbar.error(t.get('course.navItem.certificates.editor.save_failed'));
|
||||
}
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
|
||||
+4
-3
@@ -11,6 +11,7 @@
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import type { CertificationEvaluationData } from '$features/course/utils/types';
|
||||
import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils';
|
||||
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
|
||||
|
||||
let isLoading = $state(false);
|
||||
@@ -59,7 +60,7 @@
|
||||
return {
|
||||
studentName: $profile.fullname || 'Recipient',
|
||||
studentId: $profile.id || undefined,
|
||||
issuedAt: new Date().toISOString()
|
||||
issuedAt: normalizeCertificateIssuedAt(evaluation?.certificateEarnedAt)
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -90,7 +91,7 @@
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
snackbar.error('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
@@ -112,7 +113,7 @@
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error downloading image', error);
|
||||
snackbar.error($t('course.navItem.certificates.unexpected_error'));
|
||||
snackbar.error('course.navItem.certificates.unexpected_error');
|
||||
}
|
||||
|
||||
isPngLoading = false;
|
||||
|
||||
Reference in New Issue
Block a user