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

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
@@ -0,0 +1,42 @@
<script lang="ts">
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
import type { OrganizationAudienceMember } from '$features/org/utils/types';
interface Props {
open: boolean;
member: OrganizationAudienceMember | null;
isDeleting?: boolean;
onDelete: () => void | Promise<void>;
}
let { open = $bindable(false), member, isDeleting = false, onDelete }: Props = $props();
</script>
<Dialog.Root bind:open>
<Dialog.Content class="w-full max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('audience.delete.title')}</Dialog.Title>
<Dialog.Description>{$t('audience.delete.description')}</Dialog.Description>
</Dialog.Header>
<div class="space-y-4">
<p class="text-sm">
{$t('audience.delete.confirmation', {
email: member?.email ?? '',
name: member?.name ?? member?.email ?? ''
})}
</p>
<div class="flex justify-end gap-2">
<Button variant="outline" onclick={() => (open = false)} disabled={isDeleting}>
{$t('audience.delete.cancel')}
</Button>
<Button variant="destructive" onclick={onDelete} loading={isDeleting} disabled={!member || isDeleting}>
{$t('audience.delete.confirm')}
</Button>
</div>
</div>
</Dialog.Content>
</Dialog.Root>
@@ -20,16 +20,31 @@
selected: boolean;
onToggleSelect: () => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDelete: () => void;
}
let { row, memberDetailHref, selected, onToggleSelect, inviteActionEmail, onResendInvite, onRevokeInvite }: Props =
$props();
let {
row,
memberDetailHref,
selected,
onToggleSelect,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite,
onDelete
}: Props = $props();
const showInviteActions = $derived(
!row.profileId && (canResendAudienceInvite(row.status) || canRevokeAudienceInvite(row.status))
);
const isActionDisabled = $derived(inviteActionEmail === row.email || deletingMemberId === String(row.id));
const showRowActions = $derived(showInviteActions || canDeleteMembers);
</script>
<Table.Row>
@@ -55,31 +70,40 @@
</Table.Cell>
<Table.Cell>{row.createdAt}</Table.Cell>
<Table.Cell class="text-right">
{#if showInviteActions}
{#if showRowActions}
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="hover:ui:bg-muted inline-flex items-center justify-center rounded-md p-1.5"
aria-label={$t('audience.invite.row_actions_aria')}
disabled={inviteActionEmail !== null}
disabled={isActionDisabled}
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon class="ui:size-4 ui:text-muted-foreground" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
{#if canResendAudienceInvite(row.status)}
<DropdownMenu.Item disabled={inviteActionEmail === row.email} onclick={() => onResendInvite(row.email)}>
<DropdownMenu.Item disabled={isActionDisabled} onclick={() => onResendInvite(row.email)}>
{$t('audience.invite.resend')}
</DropdownMenu.Item>
{/if}
{#if canRevokeAudienceInvite(row.status)}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={inviteActionEmail === row.email}
disabled={isActionDisabled}
onclick={() => onRevokeInvite(row.email)}
>
{$t('audience.invite.revoke')}
</DropdownMenu.Item>
{/if}
{#if canDeleteMembers}
<DropdownMenu.Item
class="ui:text-destructive focus:ui:text-destructive"
disabled={isActionDisabled}
onclick={onDelete}
>
{$t('audience.delete.action')}
</DropdownMenu.Item>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import * as Select from '@cio/ui/base/select';
import { Search } from '@cio/ui/custom/search';
import * as Page from '@cio/ui/base/page';
import { t } from '$lib/utils/functions/translations';
@@ -8,10 +9,28 @@
hasSelection: boolean;
selectedCount: number;
searchValue?: string;
sortValue: string;
onSortChange: (value: string) => void;
onOpenAssign: () => void;
}
let { hasSelection, selectedCount, searchValue = $bindable(''), onOpenAssign }: Props = $props();
let {
hasSelection,
selectedCount,
searchValue = $bindable(''),
sortValue,
onSortChange,
onOpenAssign
}: Props = $props();
const sortOptions = [
{ label: t.get('audience.sort.newest'), value: 'createdAt:desc' },
{ label: t.get('audience.sort.oldest'), value: 'createdAt:asc' },
{ label: t.get('audience.sort.name_asc'), value: 'name:asc' },
{ label: t.get('audience.sort.name_desc'), value: 'name:desc' },
{ label: t.get('audience.sort.email_asc'), value: 'email:asc' },
{ label: t.get('audience.sort.email_desc'), value: 'email:desc' }
];
</script>
<Page.BodyHeader>
@@ -25,6 +44,22 @@
</Button>
</div>
{:else}
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="" />
<div class="flex w-full flex-col gap-2 md:flex-row md:items-center md:justify-between">
<Search placeholder={$t('audience.search_placeholder')} bind:value={searchValue} class="w-full md:max-w-sm" />
<Select.Root type="single" value={sortValue} onValueChange={onSortChange} name="audience-sort">
<Select.Trigger class="w-full md:w-[220px]">
{sortOptions.find((option) => option.value === sortValue)?.label ?? $t('audience.sort.label')}
</Select.Trigger>
<Select.Content>
<Select.Group>
{#each sortOptions as option (option.value)}
<Select.Item value={option.value} label={option.label} disabled={option.value === sortValue}>
{option.label}
</Select.Item>
{/each}
</Select.Group>
</Select.Content>
</Select.Root>
</div>
{/if}
</Page.BodyHeader>
@@ -20,8 +20,11 @@
isRowSelected: (rowId: string) => boolean;
onToggleRow: (rowId: string) => void;
inviteActionEmail: string | null;
deletingMemberId: string | null;
canDeleteMembers: boolean;
onResendInvite: (email: string) => void;
onRevokeInvite: (email: string) => void;
onDeleteRow: (row: OrganizationAudienceMember) => void;
}
let {
@@ -33,8 +36,11 @@
isRowSelected,
onToggleRow,
inviteActionEmail,
deletingMemberId,
canDeleteMembers,
onResendInvite,
onRevokeInvite
onRevokeInvite,
onDeleteRow
}: Props = $props();
function memberHref(row: OrganizationAudienceMember): string | null {
@@ -71,8 +77,11 @@
selected={isRowSelected(String(row.id))}
onToggleSelect={() => onToggleRow(String(row.id))}
{inviteActionEmail}
{deletingMemberId}
{canDeleteMembers}
{onResendInvite}
{onRevokeInvite}
onDelete={() => onDeleteRow(row)}
/>
{/each}
</Table.Body>
@@ -1,5 +1,6 @@
export { default as AssignCoursesModal } from './assign-courses-modal.svelte';
export { default as AudienceMemberRow } from './audience-member-row.svelte';
export { default as AudienceDeleteConfirmation } from './audience-delete-confirmation.svelte';
export { default as AudiencePagination } from './audience-pagination.svelte';
export { default as AudienceTable } from './audience-table.svelte';
export { default as AudienceTableSkeleton } from './audience-table-skeleton.svelte';
@@ -1,17 +1,25 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UsersIcon from '@lucide/svelte/icons/users';
import { orgApi } from '$features/org/api/org.svelte';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$features/ui';
import { currentOrgMaxAudience } from '$lib/utils/store/org';
import type { OrganizationAudience } from '$features/org/utils/types';
import { currentOrgMaxAudience, isOrgAdmin } from '$lib/utils/store/org';
import type {
OrganizationAudience,
OrganizationAudienceMember,
OrganizationAudiencePagination,
OrganizationAudienceQuery
} from '$features/org/utils/types';
import AssignCoursesModal from '$features/audience/components/assign-courses-modal.svelte';
import AudienceDeleteConfirmation from '$features/audience/components/audience-delete-confirmation.svelte';
import AudienceTableToolbar from '$features/audience/components/audience-table-toolbar.svelte';
import AudienceTableSkeleton from '$features/audience/components/audience-table-skeleton.svelte';
import AudienceTable from '$features/audience/components/audience-table.svelte';
import AudiencePagination from '$features/audience/components/audience-pagination.svelte';
import { SvelteSet } from 'svelte/reactivity';
import { DEFAULT_ORG_AUDIENCE_QUERY, getAudienceSearchParams } from '$features/org/utils/audience-query-utils';
interface Course {
id: string;
@@ -20,15 +28,17 @@
interface Props {
audience?: OrganizationAudience;
pagination?: OrganizationAudiencePagination | null;
query: OrganizationAudienceQuery;
courses?: Course[];
}
let { audience, courses = [] }: Props = $props();
let { audience, pagination = null, query, courses = [] }: Props = $props();
$effect(() => {
if (audience) {
orgApi.audience = audience;
}
orgApi.audience = audience ?? [];
orgApi.audiencePagination = pagination;
selectedIds.clear();
});
const headers = $derived([
@@ -39,11 +49,19 @@
]);
let inviteActionEmail = $state<string | null>(null);
let deletingMemberId = $state<string | null>(null);
let deleteCandidate = $state<OrganizationAudienceMember | null>(null);
let deleteDialogOpen = $state(false);
let searchValue = $state(query.search ?? '');
let sortValue = $state(`${query.sortBy}:${query.sortOrder}`);
async function handleResendInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.resendAudienceInvite({ email });
const result = await orgApi.resendAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
@@ -52,32 +70,22 @@
async function handleRevokeInvite(email: string) {
inviteActionEmail = email;
try {
await orgApi.revokeAudienceInvite({ email });
const result = await orgApi.revokeAudienceInvite({ email });
if (result) {
await refreshAudience();
}
} finally {
inviteActionEmail = null;
}
}
let pageSize = $state(20);
let currentPage = $state(1);
let searchValue = $state('');
let selectedIds = new SvelteSet<string>();
let assignModalOpen = $state(false);
const filteredRows = $derived(
searchValue
? orgApi.audience.filter((row) =>
Object.values(row).some((val) => String(val).toLowerCase().includes(searchValue.toLowerCase()))
)
: orgApi.audience
);
const totalPages = $derived(Math.ceil(filteredRows.length / pageSize));
const startIndex = $derived((currentPage - 1) * pageSize);
const endIndex = $derived(startIndex + pageSize);
const paginatedRows = $derived(filteredRows.slice(startIndex, endIndex));
const selectablePageRows = $derived(paginatedRows.filter((row) => row.profileId));
const currentPage = $derived(pagination?.page ?? DEFAULT_ORG_AUDIENCE_QUERY.page);
const pageSize = $derived(pagination?.limit ?? DEFAULT_ORG_AUDIENCE_QUERY.limit);
const totalCount = $derived(pagination?.total ?? 0);
const selectablePageRows = $derived(orgApi.audience.filter((row) => row.profileId));
const allPageSelected = $derived(
selectablePageRows.length > 0 && selectablePageRows.every((row) => selectedIds.has(String(row.id)))
@@ -117,13 +125,110 @@
}
$effect(() => {
if (filteredRows.length > 0 && currentPage > totalPages) {
currentPage = 1;
}
searchValue = query.search ?? '';
sortValue = `${query.sortBy}:${query.sortOrder}`;
});
$effect(() => {
const normalizedSearch = searchValue.trim() || undefined;
const currentSearch = query.search ?? undefined;
if (normalizedSearch === currentSearch) {
return;
}
const timeoutId = setTimeout(() => {
void navigateAudience({
...query,
page: 1,
search: normalizedSearch
});
}, 300);
return () => clearTimeout(timeoutId);
});
async function refreshAudience(pageOverride = currentPage) {
await navigateAudience({
...query,
page: pageOverride
});
}
async function navigateAudience(nextQuery: OrganizationAudienceQuery) {
const searchParams = getAudienceSearchParams(nextQuery);
const nextSearch = searchParams.toString();
const currentSearch = page.url.searchParams.toString();
await goto(`${page.url.pathname}?${nextSearch}`, {
replaceState: nextSearch === currentSearch,
keepFocus: true,
noScroll: true,
invalidateAll: true
});
}
function handlePageChange(nextPage: number) {
if (nextPage === currentPage) {
return;
}
void navigateAudience({
...query,
page: nextPage
});
}
function handleSortChange(nextSortValue: string) {
if (nextSortValue === sortValue) {
return;
}
sortValue = nextSortValue;
const [sortBy, sortOrder] = nextSortValue.split(':') as [
OrganizationAudienceQuery['sortBy'],
OrganizationAudienceQuery['sortOrder']
];
void navigateAudience({
...query,
page: 1,
sortBy,
sortOrder
});
}
function openDeleteConfirmation(member: OrganizationAudienceMember) {
deleteCandidate = member;
deleteDialogOpen = true;
}
async function handleDeleteAudienceMember() {
if (!deleteCandidate) {
return;
}
deletingMemberId = String(deleteCandidate.id);
try {
const result = await orgApi.deleteAudienceMember(Number(deleteCandidate.id));
if (!result) {
return;
}
selectedIds.delete(String(deleteCandidate.id));
const nextPage = orgApi.audience.length === 1 && currentPage > 1 ? currentPage - 1 : currentPage;
await refreshAudience(nextPage);
deleteCandidate = null;
deleteDialogOpen = false;
} finally {
deletingMemberId = null;
}
}
</script>
{#if orgApi.audience.length >= $currentOrgMaxAudience}
{#if totalCount >= $currentOrgMaxAudience}
<UpgradeBanner>{$t('audience.upgrade')}</UpgradeBanner>
{/if}
@@ -131,34 +236,30 @@
{hasSelection}
selectedCount={selectedIds.size}
bind:searchValue
{sortValue}
onSortChange={handleSortChange}
onOpenAssign={() => (assignModalOpen = true)}
/>
{#if orgApi.audience.length || orgApi.isLoading}
{#if totalCount > 0}
<div class="w-full space-y-4">
{#if orgApi.isLoading}
<AudienceTableSkeleton rowCount={pageSize} />
{:else}
<AudienceTable
{headers}
rows={paginatedRows}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
/>
{/if}
<AudiencePagination
count={filteredRows.length}
perPage={pageSize}
page={currentPage}
onPageChange={(p) => (currentPage = p)}
<AudienceTable
{headers}
rows={orgApi.audience}
{allPageSelected}
{somePageSelected}
onToggleSelectAll={toggleSelectAll}
isRowSelected={(id) => selectedIds.has(id)}
onToggleRow={toggleRow}
{inviteActionEmail}
{deletingMemberId}
canDeleteMembers={$isOrgAdmin === true}
onResendInvite={handleResendInvite}
onRevokeInvite={handleRevokeInvite}
onDeleteRow={openDeleteConfirmation}
/>
<AudiencePagination count={totalCount} perPage={pageSize} page={currentPage} onPageChange={handlePageChange} />
</div>
{:else}
<Empty title={$t('audience.no_audience')} description={$t('audience.manage')} icon={UsersIcon} variant="page" />
@@ -171,3 +272,10 @@
onclose={() => (assignModalOpen = false)}
onassigned={handleAssigned}
/>
<AudienceDeleteConfirmation
bind:open={deleteDialogOpen}
member={deleteCandidate}
isDeleting={deletingMemberId !== null}
onDelete={handleDeleteAudienceMember}
/>
@@ -9,6 +9,7 @@ import type {
EnrollCourseRequest,
GetCourseAnalyticsRequest,
GetCourseBySlugRequest,
GetCertificationEvaluationRequest,
GetCourseProgressRequest,
GetCourseRequest,
UpdateCourseData,
@@ -543,6 +544,27 @@ export class CourseApi extends BaseApiWithErrors {
});
}
async getCertificationEvaluation(courseId: string) {
return this.execute<GetCertificationEvaluationRequest>({
requestFn: () =>
classroomio.course[':courseId']['certification-evaluation'].$get({
param: { courseId }
}),
logContext: 'fetching certification evaluation',
onSuccess: (response) => {
if (response.data) {
this.success = true;
this.errors = {};
}
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('Failed to load certification status');
}
}
});
}
/**
* Gets course analytics including student progress, completion rates, and grades
* @param courseId Course ID
@@ -635,7 +657,7 @@ export class CourseApi extends BaseApiWithErrors {
const lessonTabsOrder = metadata?.lessonTabsOrder;
if (lessonTabsOrder) {
const existingTabIds = lessonTabsOrder.map((tab) => tab.id);
const allTabs = [
const allTabs: Array<{ id: 1 | 2 | 3 | 4; name: string }> = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
@@ -661,9 +683,15 @@ export class CourseApi extends BaseApiWithErrors {
};
}
// Ensure certificateTheme has default value
if (!data.certificateTheme) {
data.certificateTheme = 'professional';
if (!data.certificate) {
data.certificate = {
isDownloadable: false,
theme: 'professional'
};
}
if (!data.certificate.theme) {
data.certificate.theme = 'professional';
}
// Set the course data
@@ -32,7 +32,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'fetching exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
this.success = true;
this.errors = {};
}
@@ -64,7 +64,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -106,7 +106,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'updating exercise',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
this.success = true;
this.errors = {};
}
@@ -163,7 +163,7 @@ export class ExerciseApi extends BaseApiWithErrors {
return;
}
await this.execute<SubmitExerciseRequest>({
return await this.execute<SubmitExerciseRequest>({
requestFn: () =>
classroomio.course[':courseId'].exercise[':exerciseId'].submission.$post({
param: { courseId, exerciseId },
@@ -220,7 +220,7 @@ export class ExerciseApi extends BaseApiWithErrors {
logContext: 'creating exercise from template',
onSuccess: (response) => {
if (response.data) {
this.exercise = response.data;
this.exercise = response.data as Exercise;
snackbar.success('Exercise created successfully');
this.success = true;
this.errors = {};
@@ -38,7 +38,6 @@ import {
import {
ZLessonCommentCreate,
ZLessonCommentUpdate,
ZLessonCompletionUpdate,
ZLessonCreate,
ZLessonReorder,
ZLessonUpdate
@@ -46,8 +45,8 @@ import {
import type { TLocale } from '@cio/db/types';
import { get } from 'svelte/store';
import { mediaApi } from '$features/media/api';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { mediaApi } from '$features/media/api';
import { profile } from '$lib/utils/store/user';
import { snackbar } from '$features/ui/snackbar/store';
@@ -607,17 +606,11 @@ export class LessonApi extends BaseApiWithErrors {
* Updates lesson completion
*/
async updateCompletion(courseId: string, lessonId: string, isComplete: boolean) {
const result = ZLessonCompletionUpdate.safeParse({ isComplete });
if (!result.success) {
this.errors = mapZodErrorsToTranslations(result.error, 'lesson');
return;
}
await this.execute<UpdateLessonCompletionRequest>({
return this.execute<UpdateLessonCompletionRequest>({
requestFn: () =>
classroomio.course[':courseId'].lesson[':lessonId'].completion.$put({
param: { courseId, lessonId },
json: result.data
json: { isComplete }
}),
logContext: 'updating lesson completion',
onSuccess: (response) => {
@@ -0,0 +1,134 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Label } from '@cio/ui/base/label';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { Button } from '@cio/ui/base/button';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { CERTIFICATE_THEME_IDS, type CertificateThemeId } from '$features/course/utils/constants';
import { parseCertificateThemeId } from '$features/course/utils/certificate-utils';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
const studentNamePlaceholder = 'Name of student';
const certificateTheme = $derived(parseCertificateThemeId(courseApi.course?.certificate?.theme));
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
function onDescriptionInput(e: Event) {
if (!courseApi.course) return;
courseApi.course.description = (e.currentTarget as HTMLTextAreaElement).value;
}
function goToOrgSettings() {
goto(
resolve('/org/[slug]/settings', {
slug: $currentOrg.siteName || ''
})
);
}
</script>
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.certificates.certificate_settings')}</Field.Legend>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.theme')}</Field.Label>
<RadioGroup.Root
value={certificateTheme}
onValueChange={(value) => {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
theme: value as CertificateThemeId
};
}}
disabled={$isFreePlan}
class="mb-6"
>
<div class="flex flex-wrap justify-between gap-y-5">
{#each CERTIFICATE_THEME_IDS as theme (theme)}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.certificates.logo')}</Field.Label>
<Field.Description>
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</Field.Description>
<Button variant="outline" class="mt-3" onclick={goToOrgSettings}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</Field.Field>
<Field.Field>
<Field.Label for="cert-design-description">{$t('course.navItem.certificates.description')}</Field.Label>
<Textarea
id="cert-design-description"
rows={6}
class="w-full"
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description ?? ''}
oninput={onDescriptionInput}
disabled={$isFreePlan}
/>
<Field.Description>{helperText}</Field.Description>
{#if errors.description}
<Field.Error>{errors.description}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
</Field.Set>
</Field.Group>
</section>
<section class="flex w-full rounded-md lg:w-3/5">
<div class="w-[95%]">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { t } from '$lib/utils/functions/translations';
import { UpgradeBanner } from '$features/ui';
import CertificateDesign from './certificate-design.svelte';
import CertificateSettings from './certificate-settings.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
let activeTab = $state('design');
</script>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="px-2 md:-mr-3 md:-ml-3">
<UnderlineTabs.Root bind:value={activeTab} class="w-full">
<UnderlineTabs.List>
<UnderlineTabs.Trigger value="design">
{$t('course.navItem.certificates.tab_design')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.certificates.tab_settings')}
</UnderlineTabs.Trigger>
</UnderlineTabs.List>
<UnderlineTabs.Content value="design" class="mt-4">
<CertificateDesign {errors} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings" class="mt-4">
<CertificateSettings {errors} />
</UnderlineTabs.Content>
</UnderlineTabs.Root>
</main>
@@ -0,0 +1,186 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Input } from '@cio/ui/base/input';
import { Textarea } from '@cio/ui/base/textarea';
import * as Field from '@cio/ui/base/field';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { isFreePlan } from '$lib/utils/store/org';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { ContentType } from '@cio/utils/constants/content';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
function isoToDatetimeLocal(iso: string | null | undefined): string {
if (!iso) return '';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '';
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function updateCertificate(patch: Partial<NonNullable<typeof courseApi.course>['certificate']>) {
if (!courseApi.course) return;
courseApi.course.certificate = {
...(courseApi.course.certificate ?? {}),
...patch
};
}
function onCertDeadlineChange(e: Event) {
const v = (e.currentTarget as HTMLInputElement).value;
updateCertificate({ deadline: v ? new Date(v).toISOString() : null });
}
const certExercises = $derived(
getOrderedNavigableContent(courseApi.course).filter((item) => item.type === ContentType.Exercise)
);
const finalExerciseSelectValue = $derived(courseApi.course?.certificate?.requiredExerciseId ?? 'none');
function onFinalExerciseChange(e: Event) {
const v = (e.currentTarget as HTMLSelectElement).value;
if (!v || v === 'none') {
updateCertificate({ requiredExerciseId: null, exerciseMinScorePercent: null });
return;
}
const currentMin = courseApi.course?.certificate?.exerciseMinScorePercent;
updateCertificate({
requiredExerciseId: v,
exerciseMinScorePercent: currentMin ?? 100
});
}
function onThresholdInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ threshold: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onMinExerciseScoreInput(e: Event) {
const n = Number((e.currentTarget as HTMLInputElement).value);
updateCertificate({ exerciseMinScorePercent: Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : 100 });
}
function onEmailMessageInput(e: Event) {
updateCertificate({ emailMessage: (e.currentTarget as HTMLTextAreaElement).value || null });
}
</script>
<Field.Group class="w-full max-w-md! px-2">
<Field.Set>
<Field.Legend>{$t('course.certification.rules_heading')}</Field.Legend>
<Field.Field orientation="horizontal">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.certificate?.isDownloadable}
onCheckedChange={(checked) => {
updateCertificate({ isDownloadable: checked });
}}
disabled={$isFreePlan}
/>
<Field.Label for="certificate-downloadable" class="text-gray-600">
{$t('course.navItem.certificates.allow')}
</Field.Label>
</Field.Field>
<Field.Group>
<Field.Field>
<Field.Label for="cert-deadline">{$t('course.certification.deadline_label')}</Field.Label>
<Input
id="cert-deadline"
type="datetime-local"
class="w-full"
value={isoToDatetimeLocal(courseApi.course?.certificate?.deadline ?? null)}
onchange={onCertDeadlineChange}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.deadline_helper')}</Field.Description>
{#if errors['certificate.deadline']}
<Field.Error>{errors['certificate.deadline']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-threshold">{$t('course.certification.threshold_label')}</Field.Label>
<Input
id="cert-threshold"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.threshold ?? 100)}
oninput={onThresholdInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.threshold_helper')}</Field.Description>
{#if errors['certificate.threshold']}
<Field.Error>{errors['certificate.threshold']}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label for="cert-final-exercise">{$t('course.certification.final_exercise_label')}</Field.Label>
<select
id="cert-final-exercise"
class="ui:border-input ui:bg-background ui:text-foreground ui:ring-offset-background ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:min-w-0 ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-base ui:outline-none ui:transition-[color,box-shadow] ui:disabled:cursor-not-allowed ui:disabled:opacity-50 ui:md:text-sm ui:focus-visible:border-ring ui:focus-visible:ring-ring/50 ui:focus-visible:ring-[3px]"
value={finalExerciseSelectValue}
onchange={onFinalExerciseChange}
disabled={$isFreePlan}
>
<option value="none">{$t('course.certification.final_exercise_none')}</option>
{#each certExercises as item (item.id)}
<option value={item.id}>{item.title}</option>
{/each}
</select>
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
</Field.Field>
{#if courseApi.course?.certificate?.requiredExerciseId}
<Field.Field>
<Field.Label for="cert-min-exercise-score">{$t('course.certification.min_exercise_score_label')}</Field.Label>
<Input
id="cert-min-exercise-score"
type="number"
min={0}
max={100}
class="w-full"
value={String(courseApi.course?.certificate?.exerciseMinScorePercent ?? 100)}
oninput={onMinExerciseScoreInput}
disabled={$isFreePlan}
/>
<Field.Description>{$t('course.certification.min_exercise_score_helper')}</Field.Description>
{#if errors['certificate.exerciseMinScorePercent']}
<Field.Error>{errors['certificate.exerciseMinScorePercent']}</Field.Error>
{/if}
</Field.Field>
{/if}
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.certification.email_message_label')}</Field.Legend>
<Field.Field>
<Textarea
id="cert-email-message"
class="w-full"
rows={4}
placeholder={$t('course.certification.email_message_placeholder')}
value={courseApi.course?.certificate?.emailMessage ?? ''}
oninput={onEmailMessageInput}
disabled={$isFreePlan}
/>
{#if errors['certificate.emailMessage']}
<Field.Error>{errors['certificate.emailMessage']}</Field.Error>
{/if}
</Field.Field>
</Field.Set>
</Field.Group>
@@ -0,0 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Confetti } from '$features/ui';
import { setConfetti } from '$features/ui/confetti/store';
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
let activeCourseId = $state('');
onMount(() => {
return courseCompletionModal.subscribe((v) => {
if (v?.open && v.courseId) {
activeCourseId = v.courseId;
open = true;
setConfetti(true);
setTimeout(() => setConfetti(false), 5500);
} else {
open = false;
}
});
});
function onOpenChange(next: boolean) {
open = next;
if (!next) {
closeCourseCompletionModal();
}
}
function goToCertificate() {
if (!activeCourseId) return;
onOpenChange(false);
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
}
</script>
<div class="pointer-events-none fixed inset-0 z-100">
<Confetti />
</div>
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="ui:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -1,196 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { Label } from '@cio/ui/base/label';
import { Switch } from '@cio/ui/base/switch';
import ZapIcon from '@lucide/svelte/icons/zap';
import * as RadioGroup from '@cio/ui/base/radio-group';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import { saveCertificateValidation } from '$lib/utils/functions/validator';
import { Button } from '@cio/ui/base/button';
import Plain from './templates/plain.svelte';
import Professional from './templates/professional.svelte';
import { TextareaField } from '@cio/ui/custom/textarea-field';
import { UpgradeBanner } from '$features/ui';
import BlueBadgePattern from './templates/blue-badge-pattern.svelte';
import PurpleBadgePattern from './templates/purple-badge-pattern.svelte';
import BlueProfessionalBadge from './templates/blue-professional-badge.svelte';
import PurpleProfessionalBadge from './templates/purple-professional-badge.svelte';
const studentNamePlaceholder = 'Name of student';
const themes = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
];
let errors = $state({
description: ''
});
const helperText = $derived(
`${courseApi.course?.description?.length || 0}/200 ${$t('course.navItem.certificates.characters')}`
);
let certificateTheme = $derived<string | undefined>(courseApi.course?.certificateTheme ?? undefined);
const saveCertificate = async () => {
if (!courseApi.course) return;
// Prevent free plan users from bypassing UI restrictions
if ($isFreePlan) {
errors.description = 'Certificate customization is only available on paid plans';
return;
}
try {
const result = saveCertificateValidation({
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
if (result && Object.keys(result).length > 0) {
errors.description = $t(result.description) || $t('course.navItem.certificates.description_error');
throw new Error(errors.description);
}
errors.description = '';
await courseApi.update(courseApi.course.id!, {
description: courseApi.course?.description || '',
isCertificateDownloadable: courseApi.course?.isCertificateDownloadable || false,
certificateTheme: certificateTheme || ''
});
snackbar.success('snackbar.course_settings.success.saved');
} catch (error) {
if (error instanceof Error) {
errors.description = error.message;
} else {
errors.description = $t('course.navItem.certificates.unexpected_error');
}
}
};
</script>
<svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" />
<link
href="https://fonts.googleapis.com/css2?family=Qwitcher+Grypen&family=Roboto:wght@300;400;700&display=swap"
rel="stylesheet"
/>
</svelte:head>
<UpgradeBanner>{$t('upgrade.certificate')}</UpgradeBanner>
<main class="mt-2 px-2 md:-mr-3 md:-ml-3">
<div class="mb-3 flex h-4/5 w-full flex-1 flex-col justify-between gap-3 lg:flex-row">
<section class="h-full w-full lg:w-2/5">
<strong class="my-2 text-base font-semibold text-black dark:text-gray-100"
>{$t('course.navItem.certificates.certificate_settings')}</strong
>
<p class="my-4 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.theme')}
</p>
<RadioGroup.Root bind:value={certificateTheme} disabled={$isFreePlan} class="mb-10">
<div class="flex flex-wrap justify-between gap-y-5">
{#each themes as theme}
<div class="mr-3 flex items-start space-x-2">
<RadioGroup.Item value={theme} id={theme} />
<Label for={theme} class="cursor-pointer">
<img src={`/images/certificate_theme_${theme}.png`} alt="themes" class="h-[82px] w-[110px]" />
</Label>
</div>
{/each}
</div>
</RadioGroup.Root>
<div>
<p class="my-2 text-xs font-normal text-black dark:text-gray-100">
{$t('course.navItem.certificates.logo')}
</p>
<div>
<p class="mt-1 text-base dark:text-gray-100">
{$t('course.navItem.certificates.to_update')}
<strong class="font-semibold">{$t('course.navItem.certificates.settings')}</strong>
{$t('course.navItem.certificates.and_upload')}
</p>
<Button variant="outline" class="mt-3" onclick={() => goto(`/org/${$currentOrg.siteName}/settings`)}>
{$t('course.navItem.certificates.goto_settings')}
</Button>
</div>
<span class="my-4">
<p class="mt-4 mb-2 text-xs font-normal dark:text-gray-100">
{$t('course.navItem.certificates.description')}
</p>
<TextareaField
rows={6}
placeholder={$t('course.navItem.certificates.placeholder')}
value={courseApi.course?.description}
onchange={(e) => (courseApi.course!.description = e.currentTarget.value)}
errorMessage={errors.description}
disabled={$isFreePlan}
helperMessage={helperText}
/>
</span>
<div class="my-4 flex items-center space-x-2">
<Switch
id="certificate-downloadable"
checked={!!courseApi.course?.isCertificateDownloadable}
onCheckedChange={(checked) => (courseApi.course!.isCertificateDownloadable = checked)}
disabled={$isFreePlan}
/>
<Label for="certificate-downloadable" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.certificates.allow')}
</Label>
</div>
</div>
</section>
<section class="flex w-full items-center justify-center rounded-md bg-gray-100 lg:w-3/5 dark:bg-neutral-800">
<div class="certificate-container flex items-center justify-center">
{#if certificateTheme === 'professional'}
<Professional studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'plain'}
<Plain studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleProfessionalBadge'}
<PurpleProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueProfessionalBadge'}
<BlueProfessionalBadge studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'purpleBadgePattern'}
<PurpleBadgePattern studentName={studentNamePlaceholder} />
{:else if certificateTheme === 'blueBadgePattern'}
<BlueBadgePattern studentName={studentNamePlaceholder} />
{/if}
</div>
</section>
</div>
<div class="h-1/5">
<Button variant="secondary" onclick={saveCertificate} loading={courseApi.isLoading} disabled={$isFreePlan}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('course.navItem.certificates.save')}
</Button>
</div>
</main>
<style>
p {
color: #262626;
}
.certificate-container {
width: 95%;
max-width: 95%;
}
</style>
@@ -5,7 +5,7 @@
</script>
<div>
{#if courseApi.course?.isCertificateDownloadable === true}
{#if courseApi.course?.certificate?.isDownloadable === true}
<UnlockedCertificate />
{:else}
<LockedCertificate />
@@ -5,17 +5,55 @@
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { Button } from '@cio/ui/base/button';
import Empty from '@cio/ui/custom/empty/empty.svelte';
import { Empty } from '@cio/ui/custom/empty';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
let isCourseComplete = $state(false);
let evaluation = $state<CertificationEvaluationData | null>(null);
let courseId = $derived(courseApi.course?.id ?? '');
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
const downLoadCertificate = async () => {
if (!isCourseComplete || !courseId) return;
@@ -24,7 +62,7 @@
const response = await classroomio.course[':courseId']['download']['certificate']['$post']({
param: { courseId },
json: {
theme: `${courseApi.course?.certificateTheme}`,
theme: `${courseApi.course?.certificate?.theme ?? 'professional'}`,
studentName: `${$profile.fullname}`,
courseName: `${courseApi.course?.title}`,
courseDescription: `${courseApi.course?.description}`,
@@ -52,22 +90,24 @@
isLoading = false;
};
const hasUserCompletedCourse = async () => {
const loadEvaluation = async () => {
if (!courseId) return;
isLoading = true;
const res = await courseApi.getCertificationEvaluation(courseId);
if (res?.data) {
evaluation = res.data;
const hasEarned = res.data.certificateEarnedAt != null;
isCourseComplete = hasEarned || !!res.data.eligibleForCertificate;
const response = await courseApi.getProgress(courseId, $profile.id);
if (courseApi.success && response?.data) {
const progress = response.data;
isCourseComplete =
progress.lessonsCount === progress.lessonsCompleted && progress.exercisesCount === progress.exercisesCompleted;
if (res.data.isNewCompletion && courseId) {
openCourseCompletionModal(courseId);
}
}
isLoading = false;
};
onMount(() => {
hasUserCompletedCourse();
loadEvaluation();
});
let title = $derived(
@@ -75,15 +115,40 @@
? 'course.navItem.certificates.unlocked_certificate'
: 'course.navItem.certificates.complete_to_download_title'
);
let subtitle = $derived(
isCourseComplete
? 'course.navItem.certificates.unlocked_certificate_subtitle'
: 'course.navItem.certificates.complete_to_download_subtitle'
);
let progressLines = $derived.by(() => {
if (!evaluation) {
return [
t.get('course.certificates.progress_hint', {
current: 0,
required: courseApi.course?.certificate?.threshold ?? 100
})
];
}
const blockers = evaluation.blockers ?? [];
if (blockers.length > 0) {
return blockers.map((b) => formatBlockerMessage(b));
}
return [
t.get('course.certificates.progress_hint', {
current: evaluation.progressPercent,
required: evaluation.certificationThreshold
})
];
});
</script>
<div class="flex-1">
<Empty title={$t(title)} description={$t(subtitle)} icon={DownloadIcon} variant="page">
<Empty title={$t(title)} icon={DownloadIcon} variant="page">
<div class="ui:text-muted-foreground mx-auto mb-4 max-w-md space-y-2 text-center text-sm">
{#if isCourseComplete}
<p>{$t('course.navItem.certificates.unlocked_certificate_subtitle')}</p>
{:else}
{#each progressLines as line (line)}
<p>{line}</p>
{/each}
{/if}
</div>
<Button onclick={downLoadCertificate} disabled={!isCourseComplete} loading={isLoading}>
<DownloadIcon size={16} />
{$t('course.navItem.certificates.download_certificate')}
@@ -155,7 +155,7 @@
</script>
<Dialog.Root bind:open={$contentCreateStore.open} onOpenChange={(isOpen) => !isOpen && closeModal()}>
<Dialog.Content class="flex max-h-[680px] w-fit! max-w-2xl! min-w-[400px] flex-col overflow-hidden">
<Dialog.Content class="flex max-h-[680px] w-full! max-w-xl! min-w-[400px] flex-col overflow-hidden">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.add_content')}</Dialog.Title>
</Dialog.Header>
@@ -252,7 +252,7 @@
bind:value={title}
autoFocus={true}
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.title_placeholder')}
className="my-4 w-2/4!"
className="my-4 w-full"
/>
</div>
{:else if type === EXERCISE_CREATE_TYPE.TEMPLATE}
@@ -15,6 +15,7 @@
import { ContentType } from '@cio/utils/constants/content';
import { PREMIUM_QUESTION_TYPE_KEYS, QUESTION_TYPES } from '$features/ui/question/constants';
import { Button } from '@cio/ui/base/button';
import { Badge } from '@cio/ui/base/badge';
import { ExerciseQuestion } from '@cio/ui';
import OrderModal from './order-modal.svelte';
@@ -40,11 +41,20 @@
interface Props {
shouldDeleteExercise?: boolean;
exerciseId: any;
goBack?: any;
exerciseId: string;
goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
}
let { shouldDeleteExercise = $bindable(false), exerciseId, goBack = () => {} }: Props = $props();
let {
shouldDeleteExercise = $bindable(false),
exerciseId,
goBack = () => {},
requiresPositivePointsForAutoGrade = false,
selfPacedCourse = false
}: Props = $props();
let questionIdToDelete = $state(null);
let isDeleting = $state(false);
@@ -173,9 +183,10 @@
}
async function handleDelete() {
if (!courseApi.course?.id) return;
isDeleting = true;
await exerciseApi.delete(courseApi.course?.id!, exerciseId);
await exerciseApi.delete(courseApi.course.id, exerciseId);
if (exerciseApi.success) {
courseApi.removeContentItem(exerciseId, ContentType.Exercise);
@@ -234,6 +245,9 @@
bind:points={question.points}
hasError={!!errors[question.id]}
errorMsg={getQuestionErrorMsg(errors, question, 'points')}
pointsHint={requiresPositivePointsForAutoGrade && Number(question.points) === 0
? $t('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
: null}
onPointsChange={() => {
question.isDirty = true;
}}
@@ -272,7 +286,7 @@
{getQuestionTypeLabel(question?.questionType)}
</Select.Trigger>
<Select.Content>
{#each QUESTION_TYPES as type, i (type.key)}
{#each QUESTION_TYPES as type (type.key)}
{#if $isFreePlan && PREMIUM_QUESTION_TYPE_KEYS.has(type.key)}
<button
type="button"
@@ -288,7 +302,27 @@
<span>{getQuestionTypeLabel(type)}</span>
</button>
{:else}
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)} />
<Select.Item value={type.id.toString()} label={getQuestionTypeLabel(type)}>
{#snippet children({ selected: _sel })}
<span class="flex w-full min-w-0 items-center justify-between gap-2">
<span class="truncate">{getQuestionTypeLabel(type)}</span>
{#if selfPacedCourse}
<Badge
variant={type.autoGradable ? 'success' : 'warning'}
class="ui:shrink-0 ui:text-[10px] ui:font-normal"
>
{type.autoGradable
? $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_auto_gradable'
)
: $t(
'course.navItem.lessons.exercises.all_exercises.edit_mode.question_type_manual_grading'
)}
</Badge>
{/if}
</span>
{/snippet}
</Select.Item>
{/if}
{/each}
</Select.Content>
@@ -0,0 +1,56 @@
<script lang="ts">
import { Switch } from '@cio/ui/base/switch';
import { Label } from '@cio/ui/base/label';
import { Button } from '@cio/ui/base/button';
import { questionnaire } from './store';
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
type Props = {
exerciseId: string;
};
let { exerciseId }: Props = $props();
let saving = $state(false);
async function saveSettings() {
if (!courseApi.course?.id) return;
saving = true;
await exerciseApi.update(courseApi.course.id, exerciseId, {
allowMultipleAttempts: !!$questionnaire.allowMultipleAttempts
});
saving = false;
if (exerciseApi.success) {
snackbar.success('snackbar.exercise.success');
}
}
</script>
<div class="max-w-xl space-y-6">
<div class="flex items-center justify-between gap-4 rounded-lg border border-neutral-200 p-4 dark:border-neutral-700">
<div class="space-y-1">
<Label for="allow-multiple" class="text-sm font-medium dark:text-gray-100">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple')}
</Label>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.lessons.exercises.all_exercises.settings_allow_multiple_helper')}
</p>
</div>
<Switch
id="allow-multiple"
checked={!!$questionnaire.allowMultipleAttempts}
onCheckedChange={(checked) => {
questionnaire.update((q) => ({ ...q, allowMultipleAttempts: checked }));
}}
/>
</div>
<Button onclick={saveSettings} loading={saving} disabled={!courseApi.course?.id}>
{$t('course.navItem.lessons.exercises.all_exercises.settings_save')}
</Button>
</div>
@@ -54,6 +54,7 @@ export const questionnaire: Writable<{
isDescriptionDirty?: boolean;
questions: Question[];
totalSubmissions: number;
allowMultipleAttempts?: boolean;
}> = writable({
title: '',
dueBy: '',
@@ -62,7 +63,8 @@ export const questionnaire: Writable<{
description: '',
isDescriptionDirty: false,
questions: [],
totalSubmissions: 0
totalSubmissions: 0,
allowMultipleAttempts: false
});
export function clearQuestionnaireValidation() {
@@ -88,6 +90,15 @@ export function reset() {
clearQuestionnaireValidation();
}
/** Clears take-exercise state so the student can start another attempt (when allowed). */
export function resetStudentExerciseTake() {
questionnaireMetaData.set({
...initAnswerState,
exerciseId: null
});
clearQuestionnaireValidation();
}
/**
* Maps Zod validation errors to UI format: { questionId: { option: 'error message' } }
* This is used to display errors in the edit-mode component
@@ -29,9 +29,10 @@
import { SafeHtmlContent } from '@cio/ui/custom/safe-html-content';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { globalStore } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { ContentType } from '@cio/utils/constants/content';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
import axios from 'axios';
@@ -150,17 +151,13 @@
}));
}
courseApi.updateContentItem(exerciseId, ContentType.Exercise, { isComplete: true });
if (
$globalStore.isStudent &&
courseApi.course?.id &&
submitResult?.data &&
'certification' in submitResult.data
) {
const certification = (submitResult.data as { certification?: { isNewCompletion?: boolean } | null })
.certification;
if (certification?.isNewCompletion) {
openCourseCompletionModal(courseApi.course.id);
}
const allContentItems = getOrderedNavigableContent(courseApi.course);
const allComplete =
$globalStore.isStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
openCourseCompletionModal(courseApi.course.id);
}
}
@@ -13,6 +13,7 @@
import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
interface Props {
lessonId?: string;
@@ -65,7 +66,7 @@
const isLessonLocked = $derived(
$globalStore.isStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false)
);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked);
const isMarkCompleteDisabled = $derived(isMarkingComplete || isLessonLocked || isLessonComplete);
async function markLessonComplete(currentLessonId: string) {
isMarkingComplete = true;
@@ -78,8 +79,18 @@
await lessonApi.updateCompletion(courseId, currentLessonId, isComplete);
if (lessonApi.success) {
updateCourseContentCompletion(currentLessonId, isComplete);
snackbar.success('snackbar.lessons.success.complete_marked');
updateCourseContentCompletion(currentLessonId, isComplete);
const allComplete =
$globalStore.isStudent &&
isComplete &&
navigableContentItems.length > 0 &&
navigableContentItems.every((item) => item.isComplete);
if (allComplete) {
openCourseCompletionModal(courseId);
}
} else {
snackbar.error('snackbar.lessons.error.try_later');
}
@@ -12,12 +12,15 @@
interface Props {
id?: string;
slug?: string;
title?: string;
type?: string;
description?: string;
isPublished?: boolean;
totalLessons?: number;
totalStudents?: number;
isExplore?: boolean;
isLMS?: boolean;
tags?: Array<{
id: string;
name: string;
@@ -28,15 +31,32 @@
let {
id = '',
slug = '',
title = '',
type = '',
description = '',
isPublished = false,
totalLessons = 0,
totalStudents = 0,
isExplore = false,
isLMS = false,
tags = []
}: Props = $props();
function handleRowClick() {
if (isExplore) {
goto(resolve(`/course/${slug}`, {}));
return;
}
if (isLMS) {
goto(resolve(`/courses/${id}/lessons?next=true`, {}));
return;
}
goto(resolve(`/courses/[id]`, { id }));
}
function handleCloneCourse(e) {
e.stopPropagation();
// TODO: Clone course functionality
@@ -62,7 +82,7 @@
}
</script>
<Table.Row class="cursor-pointer" onclick={() => goto(resolve(`/courses/[id]`, { id }))}>
<Table.Row class="cursor-pointer" onclick={handleRowClick}>
<Table.Cell class="truncate"><p class="font-semibold">{title}</p></Table.Cell>
<Table.Cell class="truncate">
<p>{description}</p>
@@ -77,34 +97,38 @@
{/if}
</Table.Cell>
<Table.Cell>{totalLessons}</Table.Cell>
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<CoursePublishBadge {isPublished} />
{#if !isLMS}
<Table.Cell>{totalStudents}</Table.Cell>
<Table.Cell>
<CoursePublishBadge {isPublished} />
</Table.Cell>
{/if}
{/if}
{#if !isLMS}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
{/if}
<Table.Cell class="text-center">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="flex items-center justify-center rounded-md p-1 hover:bg-gray-100"
onclick={(e) => e.stopPropagation()}
>
<EllipsisVerticalIcon size={16} />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={handleCloneCourse}>
{$t('courses.course_card.context_menu.clone')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleShareCourse}>
{$t('courses.course_card.context_menu.share')}
</DropdownMenu.Item>
<DropdownMenu.Item onclick={handleInvite}>
{$t('courses.course_card.context_menu.invite')}
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600" onclick={handleDeleteCourse}>
{$t('courses.course_card.context_menu.delete')}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Table.Cell>
</Table.Row>
@@ -1,11 +1,14 @@
<script lang="ts">
import { onDestroy } from 'svelte';
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '@cio/ui/base/button';
import { CheckboxField } from '@cio/ui/custom/checkbox-field';
import { Search } from '@cio/ui/custom/search';
import { MultiSelectList } from '@cio/ui/custom/multi-select-list';
import { t } from '$lib/utils/functions/translations';
import { snackbar } from '$features/ui/snackbar/store';
import { orgApi } from '$features/org/api/org.svelte';
import { currentOrg } from '$lib/utils/store/org';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import { courseApi } from '$features/course/api';
import { profile } from '$lib/utils/store/user';
import type { OrgStudent } from './types';
@@ -20,29 +23,39 @@
let { courseId, students, isLoading = false, onAssigned }: Props = $props();
let searchValue = $state('');
let isSearching = $state(false);
let lastSearchValue = $state('');
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
let activeSearchRequestId = $state(0);
const selectedProfileIds = new SvelteSet<string>();
let sendEmail = $state(true);
let isSubmitting = $state(false);
const SEARCH_DEBOUNCE_MS = 350;
const filteredStudents = $derived.by(() => {
const query = searchValue.trim().toLowerCase();
const sortedStudents = [...students].sort((a, b) =>
const sortedStudents = $derived.by(() =>
[...students].sort((a, b) =>
(a.name || a.email || '').localeCompare(b.name || b.email || '', undefined, { sensitivity: 'base' })
);
if (!query) {
return sortedStudents;
}
return sortedStudents.filter((student) => {
const name = student.name?.toLowerCase() || '';
const email = student.email?.toLowerCase() || '';
return name.includes(query) || email.includes(query);
});
});
)
);
const showLoadingState = $derived(isLoading || isSearching);
const emptyMessage = $derived(
searchValue.trim()
? $t('course.navItem.people.invite_modal.no_matching_existing_students')
: $t('course.navItem.people.invite_modal.no_existing_students')
);
const selectedCount = $derived(selectedProfileIds.size);
const multiSelectItems = $derived(
sortedStudents
.filter((s): s is OrgStudent & { profileId: string } => Boolean(s.profileId))
.map((s) => ({
id: s.profileId,
label: s.name || s.email,
description: s.email
}))
);
function toggleStudent(profileId: string) {
if (selectedProfileIds.has(profileId)) {
selectedProfileIds.delete(profileId);
@@ -51,8 +64,70 @@
}
}
async function searchStudents(value: string) {
const normalizedValue = value.trim();
if (normalizedValue === lastSearchValue) {
return;
}
lastSearchValue = normalizedValue;
const orgId = $currentOrg.id;
if (!orgId) {
return;
}
const requestId = activeSearchRequestId + 1;
activeSearchRequestId = requestId;
isSearching = true;
try {
await orgApi.getOrgAudience(
orgId,
{
...DEFAULT_ORG_AUDIENCE_QUERY,
search: normalizedValue || undefined
},
{
abortPrevious: true
}
);
} finally {
if (requestId === activeSearchRequestId) {
isSearching = false;
}
}
}
function handleSearchValueChange(value: string) {
searchValue = value;
if (searchTimeout) {
clearTimeout(searchTimeout);
}
if (!value.trim()) {
void searchStudents(value);
return;
}
searchTimeout = setTimeout(() => {
void searchStudents(value);
}, SEARCH_DEBOUNCE_MS);
}
onDestroy(() => {
orgApi.cancelAudienceRequest();
if (!searchTimeout) {
return;
}
clearTimeout(searchTimeout);
});
async function assignStudents() {
if (!courseId) return;
if (selectedProfileIds.size === 0) {
snackbar.error('course.navItem.people.invite_modal.select_at_least_one_student');
return;
@@ -82,56 +157,33 @@
}
</script>
<div class="rounded-md border p-4">
<div class="space-y-1">
<p class="text-base font-semibold">{$t('course.navItem.people.invite_modal.existing_students_title')}</p>
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.existing_students_description')}
</p>
</div>
<div class="mt-4 space-y-4">
<Search placeholder={$t('course.navItem.people.invite_modal.existing_students_search')} bind:value={searchValue} />
{#if selectedCount > 0}
<p class="ui:text-muted-foreground text-sm">{$t('audience.selected_count', { count: selectedCount })}</p>
{/if}
<div class="max-h-72 space-y-2 overflow-y-auto rounded-md border p-3">
{#if isLoading}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.loading_existing_students')}
</p>
{:else if students.length === 0}
<p class="ui:text-muted-foreground text-sm">{$t('course.navItem.people.invite_modal.no_existing_students')}</p>
{:else if filteredStudents.length === 0}
<p class="ui:text-muted-foreground text-sm">
{$t('course.navItem.people.invite_modal.no_matching_existing_students')}
</p>
{:else}
{#each filteredStudents as student (student.profileId)}
<div
class="ui:hover:bg-muted/40 rounded-md px-2 py-1"
role="button"
tabindex="0"
onclick={() => student.profileId && toggleStudent(student.profileId)}
onkeydown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
if (student.profileId) toggleStudent(student.profileId);
}
}}
>
<CheckboxField
name={`existing-student-${student.profileId}`}
label={student.name || student.email}
checked={student.profileId ? selectedProfileIds.has(student.profileId) : false}
>
<span class="ui:text-muted-foreground text-sm">{student.email}</span>
</CheckboxField>
</div>
{/each}
{/if}
<div class="">
<div class="space-y-2">
<div class="max-h-72 overflow-y-auto">
<MultiSelectList
class="border-0"
{emptyMessage}
items={multiSelectItems}
isLoading={showLoadingState}
isSelected={(id) => selectedProfileIds.has(id)}
onSearchValueChange={handleSearchValueChange}
onToggle={(id) => toggleStudent(id)}
namePrefix="existing-student"
listClass="max-h-60"
searchPlaceholder={$t('course.navItem.people.invite_modal.existing_students_search')}
bind:searchValue
>
{#snippet headingSnippet()}
<p class="ui:text-sm ui:font-medium">
{$t('course.navItem.people.invite_modal.existing_students_title')} ({multiSelectItems.length})
{#if selectedCount > 0}
<span class="ui:text-muted-foreground text-xs"
>{$t('audience.selected_count', { count: selectedCount })}</span
>
{/if}
</p>
{/snippet}
</MultiSelectList>
</div>
<CheckboxField
@@ -12,6 +12,7 @@
import { currentOrg } from '$lib/utils/store/org';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import type { OrgTeamMember } from '$lib/utils/types/org';
import type { OrgStudent, Tutor } from './types';
@@ -75,10 +76,11 @@
function loadStudents(orgId: string | undefined) {
if (!orgId) return;
untrack(async () => {
isLoadingStudents = true;
try {
await orgApi.getOrgAudience(orgId);
await orgApi.getOrgAudience(orgId, DEFAULT_ORG_AUDIENCE_QUERY, { abortPrevious: true });
} finally {
isLoadingStudents = false;
}
@@ -125,7 +127,7 @@
if (!open) closeModal();
}}
>
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-6xl overflow-y-auto">
<Dialog.Content class="max-h-[80vh] w-[96vw] max-w-3xl! overflow-y-auto">
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.people.invite_modal.title')}</Dialog.Title>
</Dialog.Header>
@@ -11,6 +11,8 @@
points?: any;
hasError?: boolean;
errorMsg?: string | null;
/** Extra hint under points row (e.g. auto-grade requires non-zero points) */
pointsHint?: string | null;
onPointsChange?: any;
key?: string;
children?: import('svelte').Snippet;
@@ -23,6 +25,7 @@
points = $bindable(undefined),
hasError = false,
errorMsg = null,
pointsHint = null,
onPointsChange = () => {},
children
}: Props = $props();
@@ -67,6 +70,8 @@
{#if errorMsg}
<p class="text-xs text-red-500">{errorMsg}</p>
{:else if pointsHint}
<p class="ui:text-muted-foreground max-w-[min(100%,12rem)] text-xs">{pointsHint}</p>
{/if}
{#if onClose && !isTitle}
@@ -64,15 +64,17 @@
<span>{$t('course.navItems.nav_content')}</span>
<div class="ml-auto flex items-center gap-1">
<Plus
size={20}
class="rounded-full p-1"
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
openContentModal(id);
}}
/>
{#if !isStudent}
<Plus
size={20}
class="rounded-full p-1"
onclick={(event) => {
event.preventDefault();
event.stopPropagation();
openContentModal(id);
}}
/>
{/if}
</div>
</a>
{/snippet}
@@ -85,8 +87,8 @@
{id}
{isStudent}
className="mt-1"
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
/>
</Sidebar.Group>
@@ -26,6 +26,7 @@
let { path, id, isStudent = false, className = '', onOpenContentModal }: Props = $props();
const contentData = $derived(getCourseContent(courseApi.course));
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
</script>
<Sidebar.MenuSub class={className}>
@@ -42,7 +43,7 @@
<span class="flex-1 truncate">{section.title}</span>
<div class="ml-auto flex items-center gap-1">
{#if onOpenContentModal}
{#if canOpenContentModal}
<IconButton
variant="ghost-outline"
size="icon-xs"
@@ -285,8 +285,8 @@
{id}
{isStudent}
className="mt-1 ml-2"
onOpenContentModal={(sectionId) => openContentModal(id, sectionId)}
onEditSection={(sectionId) => openSectionEditor(id, sectionId)}
onOpenContentModal={isStudent ? undefined : (sectionId) => openContentModal(id, sectionId)}
onEditSection={isStudent ? undefined : (sectionId) => openSectionEditor(id, sectionId)}
/>
{/if}
</Sidebar.MenuItem>
@@ -1,14 +1,20 @@
<script lang="ts">
import IssueCertificateModal from '$features/course/components/ceritficate/issue-certificate-modal.svelte';
import { RoleBasedSecurity } from '$features/ui';
import Design from '$features/course/components/ceritficate/design.svelte';
import CertificateEdit from '$features/course/components/ceritficate/certificate-edit.svelte';
import StudentCertificate from '$features/course/components/ceritficate/student-certificate/index.svelte';
type Props = {
errors: Record<string, string>;
};
let { errors }: Props = $props();
</script>
<IssueCertificateModal />
<RoleBasedSecurity allowedRoles={[1, 2]}>
<Design />
<CertificateEdit {errors} />
</RoleBasedSecurity>
<RoleBasedSecurity onlyStudent allowedRoles={[3]}>
<StudentCertificate />
@@ -110,20 +110,18 @@
{/if}
{@render filterControls?.()}
{#if !isLMS}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
{#if $courseMetaDeta.view === 'list'}
<IconButton onclick={() => setViewPreference('grid')}>
<GridIcon size={16} />
</IconButton>
{:else}
<IconButton onclick={() => setViewPreference('list')}>
<ListIcon size={16} />
</IconButton>
{/if}
</Page.BodyHeader>
<div class="mx-auto w-full flex-1">
<div class="mx-auto mt-4 w-full flex-1">
{#if isLoading}
<section class={`${isLoading || courses ? 'cards-container' : ''} `}>
<CourseCardLoader />
@@ -146,15 +144,18 @@
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.type')}</Table.Head>
<Table.Head class="w-3/12 min-w-0">{$t('courses.course_card.list_view.tags')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.lessons')}</Table.Head>
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{#if !isLMS}
<Table.Head class="w-[8%]">{$t('courses.course_card.list_view.students')}</Table.Head>
<Table.Head class="w-[10%]">{$t('courses.course_card.list_view.published')}</Table.Head>
<Table.Head class="w-[6%]"></Table.Head>
{/if}
</Table.Row>
</Table.Header>
<Table.Body>
{#each courses as courseData (courseData.id)}
<CourseList
id={courseData.id}
slug={courseData.slug ?? ''}
title={courseData.title}
type={$t(`course.navItem.settings.${courseData.type?.toLowerCase()}`)}
description={courseData.description}
@@ -167,6 +168,8 @@
slug: string;
color?: string | null;
}>}
{isExplore}
{isLMS}
/>
{/each}
</Table.Body>
@@ -30,6 +30,7 @@
import { exerciseApi } from '$features/course/api';
import { courseApi } from '$features/course/api';
import EditMode from '$features/course/components/exercise/edit-mode.svelte';
import ExerciseSettingsTab from '$features/course/components/exercise/exercise-settings-tab.svelte';
import ViewMode from '$features/course/components/exercise/view-mode.svelte';
import Submissions from '$features/course/components/exercise/submissions/submissions.svelte';
import { IconButton } from '@cio/ui/custom/icon-button';
@@ -41,17 +42,19 @@
import VideoIcon from '@lucide/svelte/icons/video';
import {
getQuestionTypeId,
getQuestionTypeKeyFromId,
getQuestionTypeOptionById
} from '$features/course/components/exercise/question-type-utils';
import type { SubmissionListItem } from '$features/course/utils/types';
import { Badge } from '@cio/ui/base/badge';
import { isAutoGradableQuestionType } from '@cio/question-types';
interface Props {
exerciseId?: string;
goBack?: () => void;
isFetching?: boolean;
submissions: SubmissionListItem[];
mySubmission: SubmissionListItem | null;
mySubmissions?: SubmissionListItem[];
}
let {
@@ -59,15 +62,18 @@
goBack = () => {},
isFetching = false,
submissions,
mySubmission
mySubmissions = []
}: Props = $props();
type ExerciseTab = 'questions' | 'submissions';
type ExerciseTab = 'questions' | 'settings' | 'submissions';
function normalizeExerciseTab(tabParam: string | null): ExerciseTab {
if (tabParam === 'submissions' || tabParam === 'analytics') {
return 'submissions';
}
if (tabParam === 'settings') {
return 'settings';
}
return 'questions';
}
@@ -109,6 +115,26 @@
return;
}
if (requiresPositivePointsForAutoGrade) {
const zeroPointQuestions = ($questionnaire.questions ?? []).filter((q) => !q.deletedAt && Number(q.points) === 0);
if (zeroPointQuestions.length > 0) {
const pointErrors: Record<string, { option?: string; title?: string; points?: string }> = {};
for (const q of zeroPointQuestions) {
pointErrors[q.id] = {
points: t.get('course.navItem.lessons.exercises.all_exercises.points_required_auto_grade')
};
}
questionnaireValidation.set(pointErrors);
snackbar.error('snackbar.exercise.points_required_auto_grade');
return;
}
}
isSaving = true;
reset();
@@ -204,11 +230,41 @@
)
);
const isExerciseUnlocked = $derived(exerciseContentItem?.isUnlocked ?? false);
const isSelfPacedCourse = $derived(courseApi.course?.type === 'SELF_PACED');
const activeQuestionTypeIds = $derived(
($questionnaire.questions ?? []).filter((q) => !q.deletedAt).map((q) => getQuestionTypeId(q))
);
function isExerciseFullyAutoGradable(typeIds: number[]): boolean {
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
}
/** When true, every question must have points &gt; 0 before save */
const requiresPositivePointsForAutoGrade = $derived(
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
);
const teacherAutoGradeBadge = $derived.by(() => {
if ($globalStore.isStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
});
</script>
<Page.Header isSticky={true} class="z-250! min-h-[36px]">
<Page.HeaderContent>
<Page.Title>{$questionnaire.title || 'Exercise'}</Page.Title>
<Page.Title class="flex flex-wrap items-center gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
<RoleBasedSecurity allowedRoles={[1, 2]}>
{#if teacherAutoGradeBadge === 'auto'}
<Badge variant="success" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.auto_graded_badge')}
</Badge>
{:else if teacherAutoGradeBadge === 'manual'}
<Badge variant="warning" class="font-normal">
{$t('course.navItem.lessons.exercises.all_exercises.manual_grading_required_badge')}
</Badge>
{/if}
</RoleBasedSecurity>
</Page.Title>
</Page.HeaderContent>
<Page.Action>
<div class="flex items-center gap-2">
@@ -278,7 +334,7 @@
<div class="overflow-x-hidden">
{#if $globalStore.isStudent}
{#if isExerciseUnlocked}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{:else}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -290,10 +346,13 @@
{/if}
{:else}
<UnderlineTabs.Root bind:value={selectedTab} class="mb-4">
<UnderlineTabs.List class="grid w-full max-w-xs grid-cols-2">
<UnderlineTabs.List class="grid w-full max-w-lg grid-cols-3">
<UnderlineTabs.Trigger value="questions">
{$t('course.navItem.lessons.exercises.all_exercises.questions')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="settings">
{$t('course.navItem.lessons.exercises.all_exercises.settings')}
</UnderlineTabs.Trigger>
<UnderlineTabs.Trigger value="submissions">
{$t('course.navItem.lessons.exercises.all_exercises.analytics.submissions')}
@@ -303,11 +362,20 @@
<UnderlineTabs.Content value="questions">
<UpdateDescription {preview} />
{#if !preview}
<EditMode bind:shouldDeleteExercise {exerciseId} {goBack} />
<EditMode
bind:shouldDeleteExercise
{exerciseId}
{goBack}
{requiresPositivePointsForAutoGrade}
selfPacedCourse={isSelfPacedCourse}
/>
{:else}
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmission} />
<ViewMode {preview} {exerciseId} isFetchingExercise={isFetching} {mySubmissions} />
{/if}
</UnderlineTabs.Content>
<UnderlineTabs.Content value="settings">
<ExerciseSettingsTab {exerciseId} />
</UnderlineTabs.Content>
<UnderlineTabs.Content value="submissions">
<Submissions bind:exerciseId {submissions} />
</UnderlineTabs.Content>
@@ -35,6 +35,12 @@
import { currentOrgDomain, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { page } from '$app/stores';
interface Props {
hasUnsavedChanges?: boolean;
}
let { hasUnsavedChanges = $bindable(false) }: Props = $props();
let isLoading = $state(false);
let isDeleting = $state(false);
let errors: {
@@ -45,7 +51,6 @@
description: undefined
});
let avatar: string | undefined;
let hasUnsavedChanges = $state(false);
let openDeleteModal = $state(false);
let selectedTagIds = $state<string[]>([]);
let initialTagIds = $state<string[]>([]);
+13 -13
View File
@@ -3,7 +3,14 @@ import type { Course } from '$features/course/types';
import type { CourseMembers, CourseSectionWithLessons, ListLessons } from '$features/course/utils/types';
import { writable, type Writable } from 'svelte/store';
export const defaultCourse = {
const defaultLessonTabsOrder: NonNullable<NonNullable<Course['metadata']>['lessonTabsOrder']> = [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
];
export const defaultCourse: Course = {
id: '',
title: '',
description: '',
@@ -12,8 +19,10 @@ export const defaultCourse = {
cost: 0,
currency: 'USD',
status: 'ACTIVE',
isCertificateDownloadable: false,
certificateTheme: 'professional',
certificate: {
isDownloadable: false,
theme: 'professional'
},
isPublished: false,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
@@ -24,10 +33,6 @@ export const defaultCourse = {
bannerImage: null,
attendance: [],
group: null,
course_section: [],
sections: [],
lessons: [],
exercises: [],
content: {
grouped: false,
sections: [],
@@ -52,12 +57,7 @@ export const defaultCourse = {
description: '',
imgUrl: ''
},
lessonTabsOrder: [
{ id: 1, name: 'course.navItem.lessons.materials.tabs.note.title' },
{ id: 2, name: 'course.navItem.lessons.materials.tabs.slide.title' },
{ id: 3, name: 'course.navItem.lessons.materials.tabs.video.title' },
{ id: 4, name: 'course.navItem.lessons.materials.tabs.document.title' }
],
lessonTabsOrder: defaultLessonTabsOrder,
grading: false,
lessonDownload: true,
allowNewStudent: false,
@@ -0,0 +1,16 @@
import { writable } from 'svelte/store';
export type CourseCompletionModalState = {
open: boolean;
courseId: string;
};
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
export function openCourseCompletionModal(courseId: string) {
courseCompletionModal.set({ open: true, courseId });
}
export function closeCourseCompletionModal() {
courseCompletionModal.set(null);
}
@@ -0,0 +1,10 @@
import { CERTIFICATE_THEME_IDS, DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
const CERTIFICATE_THEME_ID_SET = new Set<string>(CERTIFICATE_THEME_IDS);
export function parseCertificateThemeId(raw: string | null | undefined): CertificateThemeId {
if (raw != null && raw !== '' && CERTIFICATE_THEME_ID_SET.has(raw)) {
return raw as CertificateThemeId;
}
return DEFAULT_CERTIFICATE_THEME_ID;
}
@@ -37,6 +37,19 @@ export type CourseSortOrder = (typeof CourseSortOrder)[keyof typeof CourseSortOr
export const DEFAULT_SORT_ORDER: CourseSortOrder = CourseSortOrder.Desc;
export const CERTIFICATE_THEME_IDS = [
'professional',
'plain',
'purpleProfessionalBadge',
'blueProfessionalBadge',
'purpleBadgePattern',
'blueBadgePattern'
] as const;
export type CertificateThemeId = (typeof CERTIFICATE_THEME_IDS)[number];
export const DEFAULT_CERTIFICATE_THEME_ID: CertificateThemeId = 'professional';
const COURSE_SORT_ORDER_VALUES = new Set<string>(Object.values(CourseSortOrder));
export function parseCourseSortOrder(value: string | null | undefined): CourseSortOrder {
@@ -71,16 +71,17 @@ export function formatAnswers(data: {
answers: SubmissionAnswer[];
}): Record<string, AnswerData> {
const answers: Record<string, AnswerData> = {};
const questionByIdAndName: Record<string, string> = {};
const questionKeyById: Record<string, string> = {};
for (const question of data.questions) {
questionByIdAndName[String(question.id)] = question.name ?? '';
const key = question.name ? String(question.name) : String(question.id);
questionKeyById[String(question.id)] = key;
}
for (const answer of data.answers) {
const questionName = questionByIdAndName[String(answer.questionId)];
if (questionName && answer.answerData) {
answers[questionName] = answer.answerData;
const questionKey = questionKeyById[String(answer.questionId)];
if (questionKey && answer.answerData) {
answers[questionKey] = answer.answerData;
}
}
@@ -266,6 +266,13 @@ export type DeleteCourseRequest = (typeof classroomio.course)[':courseId']['$del
export type UpdateCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$put'];
export type DeleteCourseContentRequest = (typeof classroomio.course)[':courseId']['content']['$delete'];
export type GetCourseProgressRequest = (typeof classroomio.course)[':courseId']['progress']['$get'];
export type GetCertificationEvaluationRequest =
(typeof classroomio.course)[':courseId']['certification-evaluation']['$get'];
export type GetCertificationEvaluationSuccess = Extract<
InferResponseType<GetCertificationEvaluationRequest>,
{ success: true }
>;
export type CertificationEvaluationData = GetCertificationEvaluationSuccess['data'];
export type CloneCourseRequest = (typeof classroomio.course)[':courseId']['clone']['$post'];
export type EnrollCourseRequest = (typeof classroomio.course)[':courseId']['enroll']['$post'];
@@ -280,12 +287,17 @@ export type CourseMetadata = (NonNullable<ApiCourse['metadata']> extends object
: Record<string, unknown>) & {
isContentGroupingEnabled?: boolean;
};
export type CourseCertificate =
NonNullable<ApiCourse['certificate']> extends object
? NonNullable<ApiCourse['certificate']>
: Record<string, unknown>;
export type CourseContent = NonNullable<ApiCourse['content']>;
export type CourseContentSection = CourseContent['sections'][number];
export type CourseContentItem = CourseContent['items'][number];
export type Course = Omit<ApiCourse, 'metadata' | 'lessons' | 'sections' | 'exercises'> & {
metadata?: CourseMetadata | null;
certificate?: CourseCertificate | null;
content: CourseContent;
};
@@ -44,7 +44,7 @@
<UnderlineTabs.Root bind:value={currentTab}>
<UnderlineTabs.List>
{#each tabs as tab}
{#each tabs as tab (tab.value)}
<UnderlineTabs.Trigger value={tab.value}>
{$t(tab.label)}
</UnderlineTabs.Trigger>
@@ -1,5 +1,6 @@
import type {
AssignAudienceCoursesRequest,
DeleteAudienceMemberRequest,
DeleteTeamRequest,
DomainRequestRequest,
GetAudienceRequest,
@@ -8,6 +9,8 @@ import type {
InviteTeamRequest,
OrgPublicCourses,
OrganizationAudience,
OrganizationAudiencePagination,
OrganizationAudienceQuery,
OrganizationTeamMembers,
ResendAudienceInviteRequest,
RevokeAudienceInviteRequest
@@ -36,6 +39,7 @@ import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { uploadImage } from '$lib/utils/services/upload';
import { DEFAULT_ORG_AUDIENCE_QUERY, toAudienceRequestQuery } from '../utils/audience-query-utils';
export interface TOrgUpdateForm {
name?: string;
@@ -59,9 +63,16 @@ export interface TOrgUpdateForm {
class OrgApi extends BaseApiWithErrors {
teamMembers = $state<OrganizationTeamMembers>([]);
audience = $state<OrganizationAudience>([]);
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
publicCourses: OrgPublicCourses = $state([]);
isFetchingOrgPublicCourses = $state(false);
private activeAudienceRequestController: AbortController | null = null;
cancelAudienceRequest() {
this.activeAudienceRequestController?.abort();
this.activeAudienceRequestController = null;
}
/**
* Gets organization team members (non-students)
@@ -91,16 +102,52 @@ class OrgApi extends BaseApiWithErrors {
* @param orgId Organization ID
* @returns Audience array
*/
async getOrgAudience(orgId?: string) {
async getOrgAudience(
orgId?: string,
query?: Partial<OrganizationAudienceQuery>,
options: { abortPrevious?: boolean; signal?: AbortSignal } = {}
) {
if (!orgId) return;
return this.execute<GetAudienceRequest>({
requestFn: () => classroomio.organization.audience.$get(),
const requestQuery = toAudienceRequestQuery({
...DEFAULT_ORG_AUDIENCE_QUERY,
...query
});
let requestSignal = options.signal;
let requestController: AbortController | null = null;
if (options.abortPrevious) {
this.cancelAudienceRequest();
requestController = new AbortController();
this.activeAudienceRequestController = requestController;
requestSignal = requestController.signal;
}
const response = await this.execute<GetAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.$get(
{
query: requestQuery!
},
{
init: {
signal: requestSignal
}
}
),
logContext: 'fetching organization audience',
onSuccess: (response) => {
this.audience = response.data;
this.audiencePagination = response.pagination;
}
});
if (requestController && this.activeAudienceRequestController === requestController) {
this.activeAudienceRequestController = null;
}
return response;
}
/**
@@ -384,7 +431,6 @@ class OrgApi extends BaseApiWithErrors {
* Imports users into the organization as students
*/
async importAudienceMembers(data: TImportAudienceMembers) {
console.log('importAudienceMembers', data);
return this.execute<ImportAudienceRequest>({
requestFn: () =>
classroomio.organization.audience.import.$post({
@@ -396,7 +442,8 @@ class OrgApi extends BaseApiWithErrors {
snackbar.success(
t.get('audience.import.snackbar_success', {
imported: d.imported,
skipped: d.skipped,
assigned: d.assigned ?? 0,
pendingInvitesRenewed: d.pendingInvitesRenewed ?? 0,
emailsSent: d.emailsSent
})
);
@@ -460,8 +507,6 @@ class OrgApi extends BaseApiWithErrors {
} else {
snackbar.error('audience.invite.resend_snackbar_email_failed');
}
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -480,8 +525,6 @@ class OrgApi extends BaseApiWithErrors {
logContext: 'revoking audience invite',
onSuccess: () => {
snackbar.success('audience.invite.revoke_snackbar_success');
const orgId = get(currentOrg).id;
if (orgId) this.getOrgAudience(orgId);
},
onError: (result) => {
if (typeof result === 'string') {
@@ -490,6 +533,27 @@ class OrgApi extends BaseApiWithErrors {
}
});
}
async deleteAudienceMember(memberId: number) {
return this.execute<DeleteAudienceMemberRequest>({
requestFn: () =>
classroomio.organization.audience[':memberId'].$delete({
param: { memberId: memberId.toString() }
}),
logContext: 'removing audience member',
onSuccess: () => {
snackbar.success('audience.delete.snackbar_success');
},
onError: (result) => {
if (typeof result === 'string') {
snackbar.error(result);
return;
}
snackbar.error('error' in result ? result.error : result.message);
}
});
}
}
export const orgApi = new OrgApi();
@@ -0,0 +1,58 @@
import type { OrganizationAudienceQuery, OrganizationAudienceRequestQuery } from './types';
export const DEFAULT_ORG_AUDIENCE_QUERY: OrganizationAudienceQuery = {
page: 1,
limit: 20,
sortBy: 'createdAt',
sortOrder: 'desc'
};
export function getAudienceQueryFromSearchParams(
searchParams: URLSearchParams,
defaults: OrganizationAudienceQuery = DEFAULT_ORG_AUDIENCE_QUERY
): OrganizationAudienceQuery {
const page = Number(searchParams.get('page'));
const limit = Number(searchParams.get('limit'));
const sortBy = searchParams.get('sortBy');
const sortOrder = searchParams.get('sortOrder');
const search = searchParams.get('search')?.trim() || undefined;
return {
page: Number.isFinite(page) && page > 0 ? Math.floor(page) : defaults.page,
limit: Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : defaults.limit,
sortBy: sortBy === 'name' || sortBy === 'email' || sortBy === 'createdAt' ? sortBy : defaults.sortBy,
sortOrder: sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : defaults.sortOrder,
search
};
}
export function getAudienceSearchParams(query: OrganizationAudienceQuery): URLSearchParams {
const searchParams = new URLSearchParams();
searchParams.set('page', String(query.page));
searchParams.set('limit', String(query.limit));
searchParams.set('sortBy', query.sortBy);
searchParams.set('sortOrder', query.sortOrder);
if (query.search) {
searchParams.set('search', query.search);
}
return searchParams;
}
export function toAudienceRequestQuery(
query?: Partial<OrganizationAudienceQuery>
): OrganizationAudienceRequestQuery | undefined {
if (!query) {
return undefined;
}
return {
page: query.page != null ? String(query.page) : undefined,
limit: query.limit != null ? String(query.limit) : undefined,
search: query.search,
sortBy: query.sortBy,
sortOrder: query.sortOrder
};
}
@@ -1,4 +1,4 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
// Team member types
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
@@ -20,9 +20,19 @@ export type OrganizationTeamMembers = Array<
export type GetAudienceRequest = (typeof classroomio.organization)['audience']['$get'];
export type OrganizationAudienceResponse = InferResponseType<GetAudienceRequest> | null;
export type OrganizationAudienceSuccess = Extract<InferResponseType<GetAudienceRequest>, { success: true }>;
export type OrganizationAudienceRequestQuery = NonNullable<InferRequestType<GetAudienceRequest>['query']>;
export type OrganizationAudienceSortBy = 'createdAt' | 'name' | 'email';
export type OrganizationAudienceSortOrder = 'asc' | 'desc';
export type OrganizationAudienceQuery = {
page: number;
limit: number;
search?: string;
sortBy: OrganizationAudienceSortBy;
sortOrder: OrganizationAudienceSortOrder;
};
export type OrganizationAudience = OrganizationAudienceSuccess['data'];
export type OrganizationAudiencePagination = OrganizationAudienceSuccess['pagination'];
export type OrganizationAudienceMember = OrganizationAudience[number];
// Org Public Courses types
@@ -43,6 +53,8 @@ export type InviteTeamData = InviteTeamSuccess['data'];
export type DeleteTeamRequest = (typeof classroomio.organization)['team'][':memberId']['$delete'];
export type DeleteTeamSuccess = Extract<InferResponseType<DeleteTeamRequest>, { success: true }>;
export type DeleteAudienceMemberRequest = (typeof classroomio.organization)['audience'][':memberId']['$delete'];
export type DeleteAudienceMemberSuccess = Extract<InferResponseType<DeleteAudienceMemberRequest>, { success: true }>;
// update organization types
export type UpdateOrganizationRequest = (typeof classroomio.organization)['$put'];
@@ -370,7 +370,7 @@
</Field.Field>
{:else if currentDomainStatus !== 'verified'}
<Field.Field>
<div class="rounded-md border px-4 py-3 text-sm ui:text-muted-foreground">
<div class="ui:text-muted-foreground rounded-md border px-4 py-3 text-sm">
{$t('components.settings.domains.reconnect_hint')}
</div>
</Field.Field>
@@ -10,3 +10,10 @@ export function toggleConfetti() {
show: !confetti.show
}));
}
export function setConfetti(show: boolean) {
confettiStore.update((confetti) => ({
...confetti,
show
}));
}
@@ -18,6 +18,7 @@
HoverableItem,
PreviewIcon
} from '@cio/ui/custom/moving-icons';
import type { TCourseUpdate } from '@cio/utils/validation/course';
import { IconButton } from '@cio/ui/custom/icon-button';
import { CloseButton } from '$features/ui';
@@ -151,15 +152,22 @@
course.slug = course.slug || generateSlug(course.title, { appendTimestamp: true });
console.log('course', course);
await courseApi.update(courseId, {
const updatePayload = {
...course,
type: course.type!,
slug: course.slug!,
isPublished: course.isPublished ?? undefined,
overview: course.overview ?? undefined,
isCertificateDownloadable: course.isCertificateDownloadable ?? undefined,
certificateTheme: course.certificateTheme ?? undefined
});
certificate: course.certificate
? {
...course.certificate,
isDownloadable: course.certificate.isDownloadable ?? undefined,
theme: course.certificate.theme ?? undefined
}
: undefined
} as TCourseUpdate;
await courseApi.update(courseId, updatePayload);
loading = false;
syncCourseStore(course);
@@ -173,7 +181,7 @@
});
}
function setter(value: any, setterKey: string) {
function setter(value: unknown, setterKey: string) {
if (typeof value === 'undefined') return;
// Untrack the course change to avoid unnecessary re-renders
@@ -231,7 +239,7 @@
</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each sections as section}
{#each sections as section (section.key)}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
@@ -253,7 +261,7 @@
</Sidebar.Group>
{:else}
<Sidebar.Menu>
{#each sections as section}
{#each sections as section (section.key)}
{@const SectionIcon = section.icon}
<HoverableItem>
{#snippet children(isHovered)}
@@ -32,7 +32,8 @@ export const QUESTION_TYPES = QUESTION_TYPE_REGISTRY.filter((qt) => !HIDDEN_QUES
id: questionType.id,
key: questionType.key,
label: questionType.label,
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key]
labelKey: QUESTION_TYPE_LABEL_KEYS[questionType.key],
autoGradable: questionType.autoGradable
})
);