feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user