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
@@ -0,0 +1,119 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type {
CountryBreakdownData,
CountryBreakdownRequest,
CourseFunnelData,
CourseFunnelRequest,
LandingStatsData,
LandingStatsRequest,
PopularTypesData,
PopularTypesRequest
} from '../utils/types';
class AnalyticsApi extends BaseApi {
landing = $state<LandingStatsData | null>(null);
country = $state<CountryBreakdownData>([]);
funnel = $state<CourseFunnelData | null>(null);
popularTypes = $state<PopularTypesData>([]);
loadingLanding = $state(false);
loadingCountry = $state(false);
loadingFunnel = $state(false);
loadingPopularTypes = $state(false);
range = $state<7 | 30 | 90>(30);
lastFetchedOrgId = $state<string | null>(null);
lastFetchedRange = $state<7 | 30 | 90 | null>(null);
get loading() {
return this.loadingLanding || this.loadingCountry || this.loadingFunnel || this.loadingPopularTypes;
}
private query(orgId: string, bust: boolean) {
return { orgId, days: String(this.range), ...(bust && { bust: '1' }) };
}
async fetchLanding(orgId: string, bust = false) {
this.loadingLanding = true;
await this.execute<LandingStatsRequest>({
requestFn: () => classroomio.dash['landing-stats'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics landing stats',
onSuccess: (response) => {
this.landing = response.data;
}
});
this.loadingLanding = false;
}
async fetchCountry(orgId: string, bust = false) {
this.loadingCountry = true;
await this.execute<CountryBreakdownRequest>({
requestFn: () => classroomio.dash['country-breakdown'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics country breakdown',
onSuccess: (response) => {
this.country = response.data;
}
});
this.loadingCountry = false;
}
async fetchFunnel(orgId: string, bust = false) {
this.loadingFunnel = true;
await this.execute<CourseFunnelRequest>({
requestFn: () => classroomio.dash['course-funnel'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics course funnel',
onSuccess: (response) => {
this.funnel = response.data;
}
});
this.loadingFunnel = false;
}
async fetchPopularTypes(orgId: string, bust = false) {
this.loadingPopularTypes = true;
await this.execute<PopularTypesRequest>({
requestFn: () => classroomio.dash['popular-types'].$get({ query: this.query(orgId, bust) }),
logContext: 'fetching analytics popular types',
onSuccess: (response) => {
this.popularTypes = response.data;
}
});
this.loadingPopularTypes = false;
}
/** Fire all four in parallel; each card shows its own spinner. */
fetchAll(orgId: string, bust = false) {
if (!orgId) return Promise.resolve();
this.lastFetchedOrgId = orgId;
this.lastFetchedRange = this.range;
return Promise.all([
this.fetchLanding(orgId, bust),
this.fetchCountry(orgId, bust),
this.fetchFunnel(orgId, bust),
this.fetchPopularTypes(orgId, bust)
]);
}
/**
* Fetch only if data for this (orgId, range) wasn't fetched in this session.
* Singleton state means navigating away and back reuses prior data.
*/
ensureFetched(orgId: string) {
if (this.lastFetchedOrgId === orgId && this.lastFetchedRange === this.range) return;
this.fetchAll(orgId);
}
setRange(days: 7 | 30 | 90, orgId: string) {
this.range = days;
return this.fetchAll(orgId);
}
refresh(orgId: string) {
return this.fetchAll(orgId, true);
}
}
export const analyticsApi = new AnalyticsApi();
@@ -0,0 +1,26 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { cn } from '@cio/ui/tools';
import type { Snippet } from 'svelte';
interface Props {
title: string;
description?: string;
class?: string;
children?: Snippet;
}
let { title, description, class: className, children }: Props = $props();
</script>
<Card.Root class={cn(className)}>
<Card.Header>
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</Card.Header>
<Card.Content>
{@render children?.()}
</Card.Content>
</Card.Root>
@@ -0,0 +1,59 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import GlobeIcon from '@lucide/svelte/icons/globe';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { CountryBreakdownData } from '../utils/types';
interface Props {
data: CountryBreakdownData;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const maxViews = $derived(Math.max(1, ...data.map((row) => row.views)));
const totalViews = $derived(data.reduce((sum, row) => sum + row.views, 0));
</script>
<AnalyticsPanelCard
title={$t('analytics.countries.heading')}
description={data.length > 0 ? $t('analytics.countries.subtitle', { total: totalViews.toLocaleString() }) : undefined}
>
{#snippet children()}
{#if loading && data.length === 0}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if data.length === 0}
<Empty icon={GlobeIcon} title={$t('analytics.countries.empty')} />
{:else}
<div class="ui:text-muted-foreground mb-2 flex justify-between text-xs font-medium tracking-wide uppercase">
<span>{$t('analytics.countries.country')}</span>
<span>{$t('analytics.countries.visitors')}</span>
</div>
<ul class="ui:divide-border divide-y">
{#each data.slice(0, 10) as row (row.country)}
<li class="relative py-2.5">
<div
class="ui:bg-primary/10 absolute inset-y-1 left-0 rounded-r"
style="width: {(row.views / maxViews) * 100}%"
aria-hidden="true"
></div>
<div class="relative flex items-center justify-between text-sm">
<span class="ui:text-foreground font-medium">{row.country}</span>
<div class="flex items-center gap-3">
<span class="ui:text-muted-foreground tabular-nums">
{row.enrollments.toLocaleString()}
{$t('analytics.countries.enrollments_short')}
</span>
<span class="ui:text-foreground tabular-nums">{row.views.toLocaleString()}</span>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,60 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { t } from '$lib/utils/functions/translations';
import { Empty } from '@cio/ui/custom/empty';
import TrendingDownIcon from '@lucide/svelte/icons/trending-down';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { CourseFunnelData } from '../utils/types';
interface Props {
data: CourseFunnelData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const stepLabel = (name: string) => $t(`analytics.funnel.step.${name}`);
const topCount = $derived(data?.steps[0]?.count ?? 0);
</script>
<AnalyticsPanelCard title={$t('analytics.funnel.heading')} description={$t('analytics.funnel.description')}>
{#snippet children()}
{#if loading && !data}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if !data || data.steps.length === 0 || topCount === 0}
<Empty icon={TrendingDownIcon} title={$t('analytics.funnel.empty')} />
{:else}
<ol class="space-y-3">
{#each data.steps as step, index (step.name)}
<li>
<div class="mb-1.5 flex items-baseline justify-between">
<div class="flex items-baseline gap-2">
<span class="ui:text-muted-foreground text-xs font-medium tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
<span class="ui:text-foreground text-sm font-medium">{stepLabel(step.name)}</span>
</div>
<div class="flex items-baseline gap-2">
<span class="ui:text-foreground text-sm tabular-nums">{step.count.toLocaleString()}</span>
{#if step.conversionFromPrev != null}
<span class="ui:bg-primary/10 ui:text-primary rounded px-1.5 py-0.5 text-xs font-medium tabular-nums">
{Math.round(step.conversionFromPrev * 100)}%
</span>
{/if}
</div>
</div>
<div class="ui:bg-muted h-2 overflow-hidden rounded-full">
<div
class="ui:bg-primary h-full rounded-full transition-all"
style="width: {topCount > 0 ? (step.count / topCount) * 100 : 0}%"
></div>
</div>
</li>
{/each}
</ol>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,9 @@
export { default as AnalyticsPanelCard } from './analytics-panel-card.svelte';
export { default as RangePicker } from './range-picker.svelte';
export { default as RefreshButton } from './refresh-button.svelte';
export { default as CountriesTable } from './countries-table.svelte';
export { default as Funnel } from './funnel.svelte';
export { default as PopularTypes } from './popular-types.svelte';
export { default as LandingKpis } from './landing-kpis.svelte';
export { default as KpiCard } from './kpi-card.svelte';
export { default as TrendChart } from './trend-chart.svelte';
@@ -0,0 +1,83 @@
<script lang="ts">
import * as Card from '@cio/ui/base/card';
import { Spinner } from '@cio/ui/base/spinner';
import { cn } from '@cio/ui/tools';
import type { Component } from 'svelte';
interface Props {
title: string;
value: number | string;
description?: string;
icon?: Component;
accent?: 'primary' | 'success' | 'warning' | 'danger';
sparkline?: number[];
loading?: boolean;
class?: string;
}
let {
title,
value,
description,
icon: Icon,
accent = 'primary',
sparkline,
loading = false,
class: className
}: Props = $props();
const accentClass: Record<NonNullable<Props['accent']>, string> = {
primary: 'ui:bg-primary/10 ui:text-primary',
success: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
warning: 'bg-amber-500/10 text-amber-600 dark:text-amber-400',
danger: 'bg-rose-500/10 text-rose-600 dark:text-rose-400'
};
const sparklinePath = $derived.by(() => {
if (!sparkline || sparkline.length < 2) return '';
const max = Math.max(...sparkline, 1);
const min = Math.min(...sparkline, 0);
const span = max - min || 1;
const width = 100;
const height = 30;
return sparkline
.map((value, index) => {
const x = (index / (sparkline.length - 1)) * width;
const y = height - ((value - min) / span) * height;
return `${index === 0 ? 'M' : 'L'}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(' ');
});
</script>
<Card.Root class={cn('ui:bg-card relative overflow-hidden', className)}>
<Card.Header class="pb-2">
<div class="flex items-start justify-between gap-2">
<Card.Title class="ui:text-muted-foreground text-xs font-medium tracking-wide uppercase">
{title}
</Card.Title>
{#if Icon}
<div class={cn('flex h-9 w-9 items-center justify-center rounded-full', accentClass[accent])}>
<Icon class="h-4 w-4" />
</div>
{/if}
</div>
</Card.Header>
<Card.Content class="pt-0">
{#if loading}
<div class="flex h-9 items-center">
<Spinner class="ui:text-muted-foreground size-5" />
</div>
{:else}
<p class="ui:text-foreground text-3xl font-semibold tabular-nums">{value}</p>
{/if}
{#if description}
<p class="ui:text-muted-foreground mt-1 text-xs">{description}</p>
{/if}
{#if sparklinePath && !loading}
<svg viewBox="0 0 100 30" class="mt-3 h-8 w-full" preserveAspectRatio="none" aria-hidden="true">
<path d={sparklinePath} fill="none" stroke="currentColor" stroke-width="1.5" class="ui:text-primary" />
</svg>
{/if}
</Card.Content>
</Card.Root>
@@ -0,0 +1,66 @@
<script lang="ts">
import { t } from '$lib/utils/functions/translations';
import EyeIcon from '@lucide/svelte/icons/eye';
import UsersIcon from '@lucide/svelte/icons/users';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import UserPlusIcon from '@lucide/svelte/icons/user-plus';
import KpiCard from './kpi-card.svelte';
import type { LandingStatsData } from '../utils/types';
interface Props {
data: LandingStatsData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const totals = $derived(
data?.totals ?? {
landingViews: 0,
coursePageViews: 0,
uniqueVisitors: 0,
enrollments: 0,
completions: 0
}
);
const viewSpark = $derived(data?.sparkline.map((row) => row.views) ?? []);
const enrollSpark = $derived(data?.sparkline.map((row) => row.enrollments) ?? []);
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<KpiCard
title={$t('analytics.kpi.landing_views')}
value={totals.landingViews.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={EyeIcon}
accent="primary"
sparkline={viewSpark}
/>
<KpiCard
title={$t('analytics.kpi.unique_visitors')}
value={totals.uniqueVisitors.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={UsersIcon}
accent="success"
/>
<KpiCard
title={$t('analytics.kpi.course_page_views')}
value={totals.coursePageViews.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={BookOpenIcon}
accent="warning"
/>
<KpiCard
title={$t('analytics.kpi.enrollments')}
value={totals.enrollments.toLocaleString()}
description={$t('analytics.kpi.range_description')}
loading={loading && !data}
icon={UserPlusIcon}
accent="primary"
sparkline={enrollSpark}
/>
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import { Spinner } from '@cio/ui/base/spinner';
import { Empty } from '@cio/ui/custom/empty';
import LayersIcon from '@lucide/svelte/icons/layers';
import { t } from '$lib/utils/functions/translations';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { PopularTypesData } from '../utils/types';
interface Props {
data: PopularTypesData;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const maxEnrollments = $derived(Math.max(1, ...data.map((row) => row.enrollments)));
const totalEnrollments = $derived(data.reduce((sum, row) => sum + row.enrollments, 0));
function typeLabel(type: string) {
return $t(`analytics.popularTypes.types.${type}`, { default: type });
}
</script>
<AnalyticsPanelCard
title={$t('analytics.popularTypes.heading')}
description={data.length > 0
? $t('analytics.popularTypes.subtitle', { total: totalEnrollments.toLocaleString() })
: $t('analytics.popularTypes.description')}
>
{#snippet children()}
{#if loading && data.length === 0}
<div class="flex h-32 items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if data.length === 0}
<Empty icon={LayersIcon} title={$t('analytics.popularTypes.empty')} />
{:else}
<ul class="ui:divide-border divide-y">
{#each data as row (row.type)}
<li class="relative py-3">
<div
class="ui:bg-primary/10 absolute inset-y-1 left-0 rounded-r"
style="width: {(row.enrollments / maxEnrollments) * 100}%"
aria-hidden="true"
></div>
<div class="relative flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="ui:text-foreground text-sm font-medium">{typeLabel(row.type)}</p>
<p class="ui:text-muted-foreground text-xs">
{$t('analytics.popularTypes.course_count', { count: row.courseCount })}
</p>
</div>
<div class="text-right">
<p class="ui:text-foreground text-sm font-semibold tabular-nums">
{row.enrollments.toLocaleString()}
</p>
<p class="ui:text-muted-foreground text-xs tabular-nums">
{row.views.toLocaleString()}
{$t('analytics.popularTypes.views_short')}
</p>
</div>
</div>
</li>
{/each}
</ul>
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,25 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { t } from '$lib/utils/functions/translations';
interface Props {
value: 7 | 30 | 90;
onChange: (value: 7 | 30 | 90) => void;
}
let { value, onChange }: Props = $props();
const options: Array<{ days: 7 | 30 | 90; labelKey: string }> = [
{ days: 7, labelKey: 'analytics.range.7d' },
{ days: 30, labelKey: 'analytics.range.30d' },
{ days: 90, labelKey: 'analytics.range.90d' }
];
</script>
<div class="inline-flex gap-1 rounded-md border p-1">
{#each options as opt (opt.days)}
<Button variant={value === opt.days ? 'default' : 'ghost'} size="sm" onclick={() => onChange(opt.days)}>
{$t(opt.labelKey)}
</Button>
{/each}
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
import { t } from '$lib/utils/functions/translations';
interface Props {
loading: boolean;
onClick: () => void;
}
let { loading, onClick }: Props = $props();
</script>
<Button variant="outline" size="sm" disabled={loading} onclick={onClick}>
<RefreshIcon class={loading ? 'animate-spin' : ''} />
{$t('analytics.refresh')}
</Button>
@@ -0,0 +1,52 @@
<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 TrendingUpIcon from '@lucide/svelte/icons/trending-up';
import { t } from '$lib/utils/functions/translations';
import AnalyticsPanelCard from './analytics-panel-card.svelte';
import type { LandingStatsData } from '../utils/types';
interface Props {
data: LandingStatsData | null;
loading?: boolean;
}
let { data, loading = false }: Props = $props();
const chartConfig = $derived({
views: {
label: $t('analytics.trend.views'),
color: 'var(--primary)'
},
enrollments: {
label: $t('analytics.trend.enrollments'),
color: 'var(--chart-2, var(--primary))'
}
} satisfies Chart.ChartConfig);
const series = [
{ key: 'views', value: 'views', label: 'Views', color: 'var(--color-views)' },
{ key: 'enrollments', value: 'enrollments', label: 'Enrollments', color: 'var(--color-enrollments)' }
];
const hasData = $derived(
(data?.sparkline.length ?? 0) > 0 && data?.sparkline.some((r) => r.views > 0 || r.enrollments > 0)
);
</script>
<AnalyticsPanelCard title={$t('analytics.trend.heading')} description={$t('analytics.trend.description')}>
{#snippet children()}
{#if loading && !data}
<div class="flex h-[280px] items-center justify-center">
<Spinner class="ui:text-muted-foreground size-6" />
</div>
{:else if hasData && data}
<Chart.ChartContainer class="h-[280px] w-full" config={chartConfig}>
<Chart.AreaChart data={data.sparkline} x="date" axis="x" {series} />
</Chart.ChartContainer>
{:else}
<Empty icon={TrendingUpIcon} title={$t('analytics.trend.empty')} class="h-[280px]" />
{/if}
{/snippet}
</AnalyticsPanelCard>
@@ -0,0 +1,2 @@
export * from './components';
export { analyticsApi } from './api/analytics.svelte';
@@ -0,0 +1,18 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type LandingStatsRequest = (typeof classroomio.dash)['landing-stats']['$get'];
export type CountryBreakdownRequest = (typeof classroomio.dash)['country-breakdown']['$get'];
export type CourseFunnelRequest = (typeof classroomio.dash)['course-funnel']['$get'];
export type PopularTypesRequest = (typeof classroomio.dash)['popular-types']['$get'];
export type LandingStatsSuccess = Extract<InferResponseType<LandingStatsRequest>, { success: true }>;
export type CountryBreakdownSuccess = Extract<InferResponseType<CountryBreakdownRequest>, { success: true }>;
export type CourseFunnelSuccess = Extract<InferResponseType<CourseFunnelRequest>, { success: true }>;
export type PopularTypesSuccess = Extract<InferResponseType<PopularTypesRequest>, { success: true }>;
export type LandingStatsData = LandingStatsSuccess['data'];
export type CountryBreakdownData = CountryBreakdownSuccess['data'];
export type CourseFunnelData = CourseFunnelSuccess['data'];
export type PopularTypesData = PopularTypesSuccess['data'];
export type AnalyticsRange = 7 | 30 | 90;