feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -1,21 +1,17 @@
<script lang="ts">
import ChartBarIcon from '@lucide/svelte/icons/chart-bar';
import { t } from '$lib/utils/functions/translations';
import { AnalyticsPanelCard } from '$features/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
import { fade } from 'svelte/transition';
import StudentTable from '$features/course/components/analytics/student-table.svelte';
import AnalyticsGraph from '$features/course/components/analytics/analytics-graph.svelte';
import AnalyticsSkeleton from '$features/course/components/analytics/analytics-skeleton.svelte';
import AnalyticsCard from '$features/course/components/analytics/analytics-card.svelte';
import StudentsIcon from '$features/course/components/analytics/icons/students-icon.svelte';
import LessonsIcon from '$features/course/components/analytics/icons/lessons-icon.svelte';
import ProgressIcon from '$features/course/components/analytics/icons/progress-icon.svelte';
import ExercisesIcon from '$features/course/components/analytics/icons/exercises-icon.svelte';
import CourseAnalyticsKpis from '$features/course/components/analytics/course-analytics-kpis.svelte';
import EmptyState from '$features/course/components/analytics/empty-state.svelte';
import StudentTable from '$features/course/components/analytics/student-table.svelte';
import { courseApi } from '../api/course.svelte';
interface Props {
courseId: string;
courseAnalytics: CourseAnalytics | null;
}
@@ -35,70 +31,25 @@
{#if isLoading}
<AnalyticsSkeleton />
{:else if courseAnalytics}
<div class="min-h-screen" in:fade>
<!-- Main Content Area -->
<div class="px-4 py-8">
<!-- Course Overview Cards -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Students Card -->
<AnalyticsCard
title={$t('analytics.students')}
value={courseAnalytics?.totalStudents || 0}
subtitle="{$t('analytics.tutors')}: {courseAnalytics?.totalTutors || 0}"
icon={StudentsIcon}
iconColor="blue"
/>
<div class="space-y-6" in:fade>
<CourseAnalyticsKpis data={courseAnalytics} />
<!-- Lessons Card -->
<AnalyticsCard
title={$t('analytics.lessons')}
value={courseAnalytics?.totalLessons || 0}
subtitle="{$t('analytics.exercises')}: {courseAnalytics?.totalExercises || 0}"
icon={LessonsIcon}
iconColor="green"
/>
<AnalyticsGraph {courseAnalytics} />
<!-- Progress Card -->
<AnalyticsCard
title={$t('analytics.progress')}
value="{courseAnalytics?.lessonCompletionRate || 0}%"
subtitle="{$t('analytics.avg_grade')}: {courseAnalytics?.averageGrade || 0}%"
icon={ProgressIcon}
iconColor="purple"
/>
<!-- Exercise Completion Card -->
<AnalyticsCard
title={$t('analytics.exercises')}
value="{courseAnalytics?.exerciseCompletionRate || 0}%"
subtitle={$t('analytics.completion_rate')}
icon={ExercisesIcon}
iconColor="orange"
/>
</div>
</div>
<!-- Charts Section -->
<div class="mb-12">
<AnalyticsGraph {courseAnalytics} />
</div>
<!-- Students Overview Table -->
<div class="space-y-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_overview')}
</h3>
<StudentTable students={courseAnalytics?.students || []} />
</div>
</div>
<AnalyticsPanelCard
title={$t('analytics.student_progress_overview')}
description={$t('analytics.course_metrics.students_table_description')}
>
{#snippet children()}
<StudentTable students={courseAnalytics?.students ?? []} />
{/snippet}
</AnalyticsPanelCard>
</div>
{:else}
<EmptyState
title={$t('analytics.no_analytics_data')}
description={$t('analytics.no_analytics_description')}
icon={ChartBarIcon}
className="h-screen"
className="min-h-[480px]"
/>
{/if}
@@ -2,6 +2,7 @@
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Checkbox } from '@cio/ui/base/checkbox';
import * as Card from '@cio/ui/base/card';
import * as Dialog from '@cio/ui/base/dialog';
import { Empty } from '@cio/ui/custom/empty';
import * as Field from '@cio/ui/base/field';
@@ -470,14 +471,26 @@
</Tabs.List>
<Tabs.Content value="overview" class="mt-4">
<div class="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{#each summaryCards as card}
<div class="ui:bg-muted/40 rounded-lg p-4">
<p class="ui:text-muted-foreground text-xs tracking-wide uppercase">{$t(card.labelKey)}</p>
<p class="mt-2 text-lg font-semibold">{card.value}</p>
<Card.Root class="ui:bg-card">
<Card.Header class="pb-2">
<Card.Title class="text-base font-semibold"
>{$t('course.navItem.compliance.overview_metrics.heading')}</Card.Title
>
<Card.Description>{$t('course.navItem.compliance.overview_metrics.description')}</Card.Description>
</Card.Header>
<Card.Content class="pt-0">
<div class="grid grid-cols-2 gap-x-4 gap-y-6 sm:grid-cols-3">
{#each summaryCards as card}
<div class="flex min-w-0 flex-col gap-1">
<p class="ui:text-muted-foreground text-xs font-medium tracking-wide uppercase">
{$t(card.labelKey)}
</p>
<p class="ui:text-foreground text-2xl font-semibold tabular-nums">{card.value}</p>
</div>
{/each}
</div>
{/each}
</div>
</Card.Content>
</Card.Root>
</Tabs.Content>
<Tabs.Content value="learners" class="mt-4 space-y-4">
@@ -375,7 +375,7 @@
}
</script>
<Page.Header isSticky={true} class="z-100! min-h-[36px]">
<Page.Header isSticky={true} class="top-12! z-100! min-h-[36px]">
<Page.HeaderContent>
<Page.Title class="flex flex-col gap-2">
<span>{$questionnaire.title || 'Exercise'}</span>
@@ -416,7 +416,7 @@
<Page.Body>
{#snippet child()}
<div class="overflow-x-hidden lg:w-full xl:w-11/12">
<div class={`overflow-x-hidden py-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
{#if $isOrgStudent && lessonApi.lesson && !isLessonUnlocked}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
@@ -234,10 +234,11 @@
const description = value.description.trim();
const buttonLabel = value.buttonLabel.trim();
const buttonUrl = value.buttonUrl.trim();
const animation = value.animation ?? 'waves';
if (!title && !description && !buttonLabel && !buttonUrl) return null;
return { title, description, buttonLabel, buttonUrl };
return { title, description, buttonLabel, buttonUrl, animation };
}
function normalizeCallout(value: unknown): typeof $settings.callout {
@@ -253,11 +254,17 @@
return null;
}
const animation =
candidate.animation === 'dotted' || candidate.animation === 'none' || candidate.animation === 'waves'
? candidate.animation
: 'waves';
return {
title: candidate.title,
description: candidate.description,
buttonLabel: candidate.buttonLabel,
buttonUrl: candidate.buttonUrl
buttonUrl: candidate.buttonUrl,
animation
};
}
@@ -280,7 +287,7 @@
if ($settings.type === 'PUBLIC' && $settings.callout === null) {
settings.update((prev) => ({
...prev,
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '' }
callout: { title: '', description: '', buttonLabel: '', buttonUrl: '', animation: 'waves' }
}));
}
});
@@ -644,6 +651,44 @@
type="url"
/>
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.settings.callout.animation_label')}</Field.Label>
<Field.Description>
{$t('course.navItem.settings.callout.animation_description')}
</Field.Description>
<RadioGroup.Root
value={$settings.callout.animation ?? 'waves'}
onValueChange={(value) => {
if (!$settings.callout) return;
const next = value === 'dotted' || value === 'none' ? value : 'waves';
settings.update((prev) =>
prev.callout ? { ...prev, callout: { ...prev.callout, animation: next } } : prev
);
hasUnsavedChanges = true;
}}
class="ui:mt-1 flex flex-col gap-2"
>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="waves" id="callout-animation-waves" />
<Label for="callout-animation-waves">
{$t('course.navItem.settings.callout.animation_waves')}
</Label>
</Field.Field>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="dotted" id="callout-animation-dotted" />
<Label for="callout-animation-dotted">
{$t('course.navItem.settings.callout.animation_dotted')}
</Label>
</Field.Field>
<Field.Field orientation="horizontal">
<RadioGroup.Item value="none" id="callout-animation-none" />
<Label for="callout-animation-none">
{$t('course.navItem.settings.callout.animation_none')}
</Label>
</Field.Field>
</RadioGroup.Root>
</Field.Field>
</Field.Group>
<Field.Field>