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,44 +0,0 @@
<script lang="ts">
import type { Component } from 'svelte';
interface Props {
title: string;
value: string | number;
subtitle?: string;
icon: Component;
iconColor?: string;
className?: string;
}
let { title, value, subtitle = '', icon, iconColor = 'blue', className = '' }: Props = $props();
let colorClasses = $derived({
blue: 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400',
green: 'bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400',
purple: 'bg-purple-50 dark:bg-purple-900/20 text-purple-600 dark:text-purple-400',
orange: 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400',
red: 'bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400'
});
const SvelteComponent = $derived(icon);
</script>
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800 {className}">
<div class="flex items-center space-x-4">
<div class="flex h-12 w-12 items-center justify-center rounded-lg {colorClasses[iconColor]}">
<SvelteComponent className={colorClasses[iconColor]} />
</div>
<div>
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">{title}</p>
<p class="text-2xl font-semibold text-gray-900 dark:text-white">
{value}
</p>
{#if subtitle}
<p class="text-xs text-gray-500 capitalize dark:text-gray-500">
{subtitle}
</p>
{/if}
</div>
</div>
</div>
@@ -1,5 +1,9 @@
<script lang="ts">
import * as Chart from '@cio/ui/base/chart';
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import BarChartIcon from '@lucide/svelte/icons/bar-chart-3';
import { AnalyticsPanelCard } from '$features/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
@@ -29,10 +33,9 @@
}
} satisfies Chart.ChartConfig);
let hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
const hasStudentData = $derived(Boolean(courseAnalytics?.students?.length));
// Transform data for progress distribution chart
let progressChartData = $derived<DistributionChartData[]>(
const progressChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
@@ -55,8 +58,7 @@
: []
);
// Transform data for grade distribution chart
let gradeChartData = $derived<DistributionChartData[]>(
const gradeChartData = $derived<DistributionChartData[]>(
courseAnalytics?.students
? [
{
@@ -83,7 +85,7 @@
: []
);
let progressChartProps = $derived({
const progressChartProps = $derived({
xAxis: {
label: $t('analytics.progress_range')
},
@@ -92,7 +94,7 @@
}
});
let gradeChartProps = $derived({
const gradeChartProps = $derived({
xAxis: {
label: $t('analytics.grade_range')
},
@@ -101,7 +103,7 @@
}
});
let progressSeries = $derived([
const progressSeries = $derived([
{
key: 'students',
value: 'value',
@@ -110,7 +112,7 @@
}
]);
let gradeSeries = $derived([
const gradeSeries = $derived([
{
key: 'students',
value: 'value',
@@ -118,50 +120,52 @@
color: 'var(--color-students)'
}
]);
const chartsLoading = $derived(!courseAnalytics);
</script>
<div class="grid grid-cols-1 gap-8 lg:grid-cols-2">
<!-- Progress Distribution Chart -->
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">
{$t('analytics.student_progress_distribution')}
</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={progressChartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
axis="x"
props={progressChartProps}
series={progressSeries}
/>
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_progress_data')}
</div>
{/if}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
<AnalyticsPanelCard
title={$t('analytics.student_progress_distribution')}
description={$t('analytics.course_metrics.progress_distribution_card_description')}
>
{#snippet children()}
{#if chartsLoading}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[280px] w-full" config={progressChartConfig}>
<Chart.BarChart
data={progressChartData}
x="group"
axis="x"
props={progressChartProps}
series={progressSeries}
/>
</Chart.ChartContainer>
{:else}
<Empty icon={BarChartIcon} title={$t('analytics.no_progress_data')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
<!-- Grade Distribution Chart -->
<div class="">
<h3 class="mb-6 text-lg font-semibold text-gray-900 dark:text-white">{$t('analytics.grade_distribution')}</h3>
{#if !courseAnalytics}
<div class="flex h-[300px] items-center justify-center">
<div class="animate-pulse text-gray-500 dark:text-gray-400">{$t('analytics.loading_chart')}</div>
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[300px] w-full" config={gradeChartConfig}>
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
</Chart.ChartContainer>
{:else}
<div class="flex h-[300px] items-center justify-center text-gray-500 dark:text-gray-400">
{$t('analytics.no_grade_data')}
</div>
{/if}
</div>
<AnalyticsPanelCard
title={$t('analytics.grade_distribution')}
description={$t('analytics.course_metrics.grade_distribution_card_description')}
>
{#snippet children()}
{#if chartsLoading}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasStudentData}
<Chart.ChartContainer class="h-[280px] w-full" config={gradeChartConfig}>
<Chart.BarChart data={gradeChartData} x="group" axis="x" props={gradeChartProps} series={gradeSeries} />
</Chart.ChartContainer>
{:else}
<Empty icon={BarChartIcon} title={$t('analytics.no_grade_data')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
</div>
@@ -2,61 +2,54 @@
import { Skeleton } from '@cio/ui/base/skeleton';
</script>
<div class="min-h-screen">
<div class="px-4 py-8">
<!-- Course Overview Cards Skeleton -->
<div class="mb-12">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
{#each Array(4) as _, i (i)}
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<div class="flex items-center space-x-4">
<Skeleton class="h-[48px] w-[48px]" />
<div class="flex-1">
<Skeleton class="h-[48px] w-[48px]" />
<Skeleton class="mb-2 h-[16px] w-[80px]" />
<Skeleton class="mb-1 h-[24px] w-[40px]" />
<Skeleton class="h-[12px] w-[60px]" />
</div>
</div>
<div class="space-y-6">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
{#each Array(4) as _, i (i)}
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
<Skeleton class="h-3 w-24" />
</div>
<div class="px-6 py-6">
<Skeleton class="h-8 w-20" />
<Skeleton class="mt-4 h-3 w-full max-w-[180px]" />
</div>
</div>
{/each}
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
{#each Array(2) as _, chartIndex (chartIndex)}
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border ui:space-y-2 border-b px-6 py-5">
<Skeleton class="h-5 w-48" />
<Skeleton class="h-4 w-full max-w-md" />
</div>
<div class="px-6 py-6">
<Skeleton class="h-[280px] w-full" />
</div>
</div>
{/each}
</div>
<div class="ui:bg-card ui:border-border overflow-hidden rounded-xl border shadow-sm">
<div class="ui:border-border border-b px-6 py-5">
<Skeleton class="h-5 w-56" />
<Skeleton class="mt-2 h-4 max-w-xl" />
</div>
<div class="ui:divide-border divide-y px-6 py-2">
{#each Array(5) as _, i (i)}
<div class="flex flex-wrap items-center gap-4 py-4">
<Skeleton class="size-8 rounded-full" />
<div class="min-w-0 flex-1 space-y-2">
<Skeleton class="h-4 w-[150px]" />
<Skeleton class="h-3 w-[200px]" />
</div>
{/each}
</div>
</div>
<!-- Charts Section Skeleton -->
<div class="mb-12">
<div class="rounded-lg border border-gray-200 bg-white p-6 dark:border-neutral-700 dark:bg-neutral-800">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<Skeleton class="h-[300px] w-full" />
<Skeleton class="h-[300px] w-full" />
<Skeleton class="h-4 w-[80px]" />
<Skeleton class="h-4 w-[100px]" />
<Skeleton class="h-6 w-[48px]" />
<Skeleton class="h-8 w-[90px]" />
</div>
</div>
</div>
<!-- Students Table Skeleton -->
<div class="rounded-lg border border-gray-200 bg-white dark:border-neutral-700 dark:bg-neutral-800">
<div class="border-b border-gray-200 px-6 py-4 dark:border-neutral-700">
<Skeleton class="mb-6 h-[24px] w-[200px]" />
</div>
<div class="p-6">
<div class="space-y-4">
{#each Array(5) as _, i (i)}
<div class="flex items-center space-x-4">
<Skeleton class="h-[32px] w-[32px]" />
<div class="flex-1">
<Skeleton class="mb-1 h-[16px] w-[150px]" />
<Skeleton class="h-[12px] w-[200px]" />
</div>
<Skeleton class="h-[16px] w-[80px]" />
<Skeleton class="h-[16px] w-[60px]" />
<Skeleton class="h-[20px] w-[40px]" />
<Skeleton class="h-[12px] w-[80px]" />
<Skeleton class="h-[32px] w-[100px]" />
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,52 @@
<script lang="ts">
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import ListChecksIcon from '@lucide/svelte/icons/list-checks';
import PercentIcon from '@lucide/svelte/icons/percent';
import UsersIcon from '@lucide/svelte/icons/users';
import { KpiCard } from '$features/analytics';
import type { CourseAnalytics } from '$features/course/utils/types';
import { t } from '$lib/utils/functions/translations';
interface Props {
data: CourseAnalytics;
}
let { data }: Props = $props();
const tutorsCount = $derived(data.totalTutors ?? 0);
const exerciseTotal = $derived(data.totalExercises ?? 0);
const avgGrade = $derived(data.averageGrade ?? 0);
const lessonRate = $derived(data.lessonCompletionRate ?? 0);
const exerciseRate = $derived(data.exerciseCompletionRate ?? 0);
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
title={$t('analytics.students')}
value={(data.totalStudents ?? 0).toLocaleString()}
description={$t('analytics.course_metrics.kpi_team_tutors', { count: tutorsCount })}
icon={UsersIcon}
accent="primary"
/>
<KpiCard
title={$t('analytics.lessons')}
value={(data.totalLessons ?? 0).toLocaleString()}
description={$t('analytics.course_metrics.kpi_lessons_exercises', { count: exerciseTotal })}
icon={BookOpenIcon}
accent="warning"
/>
<KpiCard
title={$t('analytics.progress')}
value={`${lessonRate}%`}
description={$t('analytics.course_metrics.kpi_progress_grade', { grade: avgGrade })}
icon={PercentIcon}
accent="success"
/>
<KpiCard
title={$t('analytics.exercises')}
value={`${exerciseRate}%`}
description={$t('analytics.completion_rate')}
icon={ListChecksIcon}
accent="primary"
/>
</div>
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M3 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm0 4a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clip-rule="evenodd"
/>
</svg>
@@ -1,23 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" />
<path
fill-rule="evenodd"
d="M4 5a2 2 0 012-2h8a2 2 0 012 2v6a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 1a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
clip-rule="evenodd"
/>
</svg>
@@ -1,22 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M6.267 3.455a3.066 3.066 0 001.745-.723 3.066 3.066 0 013.976 0 3.066 3.066 0 001.745.723 3.066 3.066 0 012.812 2.812c.051.643.304 1.254.723 1.745a3.066 3.066 0 010 3.976 3.066 3.066 0 00-.723 1.745 3.066 3.066 0 01-2.812 2.812 3.066 3.066 0 00-1.745.723 3.066 3.066 0 01-3.976 0 3.066 3.066 0 00-1.745-.723 3.066 3.066 0 01-2.812-2.812 3.066 3.066 0 00-.723-1.745 3.066 3.066 0 010-3.976 3.066 3.066 0 00.723-1.745 3.066 3.066 0 012.812-2.812zm7.44 5.252a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
/>
</svg>
@@ -1,18 +0,0 @@
<script lang="ts">
interface Props {
className?: string;
}
let { className = '' }: Props = $props();
</script>
<svg
width="24"
height="24"
class="h-6 w-6 {className}"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M13 6a3 3 0 11-6 0 3 3 0 016 0zM18 8a2 2 0 11-4 0 2 2 0 014 0zM14 15a4 4 0 00-8 0v3h8v-3z" />
</svg>
@@ -59,11 +59,11 @@
/>
{:else}
<!-- Responsive Table -->
<div class="relative h-[300px] overflow-auto rounded-md border">
<div class="ui:border-border relative max-h-[min(480px,70vh)] overflow-auto rounded-md border">
<Table.Root class="min-w-[800px]">
<Table.Header class="sticky top-0 z-10 bg-white dark:bg-neutral-800">
<Table.Header class="ui:bg-muted/80 sticky top-0 z-10 backdrop-blur-sm">
<Table.Row>
<Table.Head class="sticky top-0 left-0 z-20 min-w-[200px] bg-white px-4 py-3 dark:bg-neutral-800">
<Table.Head class="ui:bg-muted/95 sticky top-0 left-0 z-20 min-w-[200px] px-4 py-3 backdrop-blur-sm">
{$t('analytics.student_name')}
</Table.Head>
<Table.Head class="min-w-[150px] px-4 py-3">{$t('analytics.lessons_completed')}</Table.Head>
@@ -77,7 +77,7 @@
{#each paginatedStudents as student (student.id)}
<Table.Row class="group h-[100px]">
<Table.Cell
class="sticky left-0 z-10 min-w-[200px] bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
class="ui:bg-card group-hover:ui:bg-muted/50 sticky left-0 z-10 min-w-[200px] px-4 py-3 transition-colors"
>
<div class="flex items-center gap-3">
<UserAvatar
@@ -86,10 +86,10 @@
class="size-8"
/>
<div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white">
<p class="ui:text-foreground truncate font-medium">
{student.profile.fullname}
</p>
<p class="truncate text-sm text-gray-500 dark:text-gray-400">
<p class="ui:text-muted-foreground truncate text-sm">
{student.profile.email}
</p>
</div>
@@ -97,18 +97,18 @@
</Table.Cell>
<Table.Cell class="min-w-[150px] px-4 py-3">
<div class="flex items-center gap-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
<span class="ui:text-foreground text-sm font-medium">
{student.lessonsCompleted}/{student.totalLessons}
</span>
<div class="w-20 flex-shrink-0">
<div class="h-2 rounded-full bg-gray-200 dark:bg-neutral-700">
<div class="ui:bg-muted h-2 rounded-full">
<div class="h-2 rounded-full bg-blue-500" style="width: {student.progressPercentage}%"></div>
</div>
</div>
</div>
</Table.Cell>
<Table.Cell class="min-w-[140px] px-4 py-3">
<span class="text-sm font-medium text-gray-900 dark:text-white">
<span class="ui:text-foreground text-sm font-medium">
{student.exercisesSubmitted} out of {student.totalExercises}
</span>
</Table.Cell>
@@ -124,7 +124,7 @@
</Badge>
</Table.Cell>
<Table.Cell class="min-w-[120px] px-4 py-3">
<span class="text-sm text-gray-500 dark:text-gray-400">
<span class="ui:text-muted-foreground text-sm">
{student.lastSeen}
</span>
</Table.Cell>
@@ -141,8 +141,8 @@
<!-- Pagination Controls -->
{#if students.length > pageSize}
<div class="mt-4 flex items-center justify-between border-t border-gray-200 px-4 py-3">
<div class="text-sm text-gray-700 dark:text-gray-300">
<div class="ui:border-border mt-4 flex items-center justify-between border-t px-4 py-3">
<div class="ui:text-muted-foreground text-sm">
{$t('analytics.showing_students', {
start: startItem,
end: endItem,