feat: setup dashboard analytics api (#604)
* feat: setup dashboard analytics api * chore: requested pr fixes * fix: dash analytics server side type * refactor: update Avatar component usage across the dashboard - Replaced custom Avatar component with the new @cio/ui/base/avatar implementation for consistency. - Enhanced avatar rendering with fallback options and improved accessibility. - Cleaned up unused Avatar component files and related functions. - Updated various components to utilize the new Avatar structure, ensuring a uniform appearance and behavior across the application. * chore: remove console.log statements from analytics routes and API calls - Eliminated console.log statements for orgId and siteName in the dash analytics router and DashStatApi class to clean up the code and improve performance. --------- Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
co-authored by
Rotimi Best
parent
c7854efae3
commit
9a5aee1ee0
@@ -4,7 +4,7 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Card from '@cio/ui/base/card';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
|
||||
const errorCode = $derived(new URLSearchParams(page.url.search).get('error'));
|
||||
</script>
|
||||
@@ -15,14 +15,13 @@
|
||||
|
||||
<AuthUI isLogin={false} showOnlyContent={true}>
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<Avatar
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
shape="rounded-md"
|
||||
width="w-10"
|
||||
height="max-h-10"
|
||||
className="mr-2"
|
||||
/>
|
||||
<Avatar.Root>
|
||||
<Avatar.Image
|
||||
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
|
||||
alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
|
||||
/>
|
||||
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<p class="text-xl font-semibold">Something went wrong</p>
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
|
||||
import TextChip from '$lib/components/Chip/Text.svelte';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { ComingSoon } from '$lib/features/ui';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { RoleBasedSecurity } from '$lib/features/ui';
|
||||
@@ -21,6 +21,7 @@
|
||||
import type { GroupPerson } from '$lib/utils/types';
|
||||
import { group } from '$lib/components/Course/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import Select from '$lib/components/Form/Select.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
|
||||
@@ -133,13 +134,13 @@
|
||||
<Table.Cell class="w-4/6 md:w-3/6">
|
||||
{#if person.profile}
|
||||
<div class="flex items-start lg:items-center">
|
||||
<Avatar
|
||||
src={person.profile.avatar_url}
|
||||
name={person.profile.fullname}
|
||||
width="w-8"
|
||||
height="h-8"
|
||||
className="mr-3"
|
||||
/>
|
||||
<Avatar.Root class="mr-3">
|
||||
<Avatar.Image
|
||||
src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
|
||||
alt={person.profile.fullname ? person.profile.fullname : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(person.profile.fullname) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="flex flex-col items-start lg:flex-row lg:items-center">
|
||||
<div class="mr-2">
|
||||
<p class="text-base font-normal dark:text-white">
|
||||
@@ -173,7 +174,7 @@
|
||||
{:else}
|
||||
<div class="flex w-2/4 items-start lg:items-center">
|
||||
<TextChip
|
||||
value={person.email.substring(0, 2).toUpperCase()}
|
||||
value={shortenName(person.email)}
|
||||
className="bg-primary-200 text-black font-semibold text-xs mr-3"
|
||||
shape="rounded-full"
|
||||
/>
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.svelte';
|
||||
@@ -441,7 +442,13 @@
|
||||
<div class="border-1 border-gray my-1 rounded-lg border px-1">
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black no-underline hover:underline">
|
||||
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
|
||||
alt={question.author.name ? question.author.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{question.createdAt}
|
||||
@@ -490,7 +497,13 @@
|
||||
<div class="border-1 border-gray w-full rounded-lg border">
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black">
|
||||
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={comment.avatar ? comment.avatar : '/logo-192.png'}
|
||||
alt={comment.name ? comment.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{comment.createdAt}
|
||||
|
||||
@@ -7,7 +7,6 @@ const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
|
||||
export const load = async ({ params, url }) => {
|
||||
const subdomain = getSubdomain(url);
|
||||
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
|
||||
|
||||
// If this is LMS but user is on org site, redirect to LMS
|
||||
if (isOrgSite) {
|
||||
redirect(307, `/lms`);
|
||||
|
||||
@@ -10,14 +10,13 @@
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { snackbar } from '$lib/components/Snackbar/store';
|
||||
import { getAccessToken } from '$lib/utils/functions/supabase';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { dashStatApi } from '$lib/features/org/api';
|
||||
|
||||
import { CreateCourseButton } from '$lib/features/course/components';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { ActivityCard } from '$lib/components/Analytics';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import Progress from '$lib/components/Progress/index.svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$lib/features/onboarding/components';
|
||||
@@ -25,60 +24,33 @@
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
|
||||
let dashAnalytics: OrganisationAnalytics | undefined = $state();
|
||||
let hasFetched = $state(false);
|
||||
const { data } = $props();
|
||||
|
||||
async function fetchDashAnalytics(orgId: string) {
|
||||
if (!orgId || hasFetched) return;
|
||||
|
||||
hasFetched = true;
|
||||
|
||||
const accessToken = await getAccessToken();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/analytics/dash', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: accessToken
|
||||
},
|
||||
body: JSON.stringify({ orgId })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(response);
|
||||
throw new Error('Failed to fetch analytics data');
|
||||
}
|
||||
|
||||
dashAnalytics = (await response.json()) as OrganisationAnalytics;
|
||||
} catch (error) {
|
||||
snackbar.error('Failed to fetch analytics data');
|
||||
}
|
||||
}
|
||||
const { enrollments, numberOfCourses, revenue, totalStudents, topCourses } = $derived(dashStatApi.stats);
|
||||
|
||||
$effect(() => {
|
||||
fetchDashAnalytics($currentOrg.id);
|
||||
dashStatApi.fetchOrgStats({ siteName: data.orgName });
|
||||
});
|
||||
|
||||
let cards = $derived([
|
||||
{
|
||||
icon: DollarSignIcon,
|
||||
title: `${$t('dashboard.revenue')} ($)`,
|
||||
percentage: dashAnalytics?.revenue ?? 0,
|
||||
percentage: revenue ?? 0,
|
||||
description: $t('dashboard.revenue_description'),
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: dashAnalytics?.numberOfCourses ?? 0,
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
},
|
||||
{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: dashAnalytics?.totalStudents ?? 0,
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}
|
||||
@@ -110,7 +82,7 @@
|
||||
<div class="mb-10 flex flex-wrap items-start">
|
||||
<div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each cards as card}
|
||||
{#if !dashAnalytics}
|
||||
{#if !revenue && !numberOfCourses && !totalStudents}
|
||||
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
|
||||
{:else}
|
||||
<ActivityCard activity={card} />
|
||||
@@ -123,21 +95,21 @@
|
||||
<div
|
||||
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
|
||||
>
|
||||
<h3 class="mt-0 text-lg">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !dashAnalytics}
|
||||
{#if !topCourses}
|
||||
{#each Array(5) as _}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each dashAnalytics.topCourses as course}
|
||||
{#each topCourses as course}
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-4/6 space-y-1">
|
||||
<a href={`/courses/${course.id}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
|
||||
<a class="hover:underline" href={`/courses/${course.id}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{course.title}
|
||||
</p>
|
||||
</a>
|
||||
@@ -148,7 +120,7 @@
|
||||
</div>
|
||||
<div class="ml-auto w-2/6">
|
||||
<Progress value={course.completion} />
|
||||
<div class="text-sm font-medium">
|
||||
<div class="text-sm">
|
||||
{course.completion}%
|
||||
{$t('dashboard.completion')}
|
||||
</div>
|
||||
@@ -171,23 +143,29 @@
|
||||
<div
|
||||
class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:border-neutral-600 dark:text-white"
|
||||
>
|
||||
<h3 class="mt-0 text-lg">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.recent_enrollments')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !dashAnalytics}
|
||||
{#if !enrollments}
|
||||
{#each Array(5) as _}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each dashAnalytics.enrollments as enrollment}
|
||||
{#each enrollments as enrollment}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<Avatar src={enrollment.avatarUrl} name={enrollment.name} width="w-6" height="h-6" />
|
||||
<Avatar.Root class="h-6 w-6">
|
||||
<Avatar.Image
|
||||
src={enrollment.avatarUrl ? enrollment.avatarUrl : '/logo-192.png'}
|
||||
alt={enrollment.name ? enrollment.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(enrollment.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm font-medium capitalize leading-none">{enrollment.name}</p>
|
||||
<p class="text-sm capitalize leading-none">{enrollment.name}</p>
|
||||
<p class="text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(enrollment.date)}
|
||||
@@ -197,8 +175,8 @@
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a href={`/courses/${enrollment.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none">
|
||||
<a class="hover:underline" href={`/courses/${enrollment.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{enrollment.course}
|
||||
</p>
|
||||
</a>
|
||||
|
||||
@@ -21,8 +21,9 @@
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import Vote from '$lib/components/Vote/index.svelte';
|
||||
import { IconButton } from '$lib/components/IconButton';
|
||||
import Avatar from '$lib/components/Avatar/index.svelte';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import TextField from '$lib/components/Form/TextField.svelte';
|
||||
import { shortenName } from '$lib/utils/functions/string';
|
||||
import TextEditor from '$lib/components/TextEditor/index.svelte';
|
||||
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
|
||||
import { CommunityDeleteModal } from '$lib/features/community/components';
|
||||
@@ -454,7 +455,13 @@
|
||||
<div class="border-gray my-1 rounded-lg border px-1">
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black no-underline hover:underline">
|
||||
<Avatar src={question.author.avatar} name={question.author.name} width="w-7" height="h-7" />
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={question.author.avatar ? question.author.avatar : '/logo-192.png'}
|
||||
alt={question.author.name ? question.author.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(question.author.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{question.author.name}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{question.createdAt}
|
||||
@@ -503,7 +510,13 @@
|
||||
<div class="border-gray w-full rounded-lg border">
|
||||
<header class="flex items-center justify-between p-2 leading-none">
|
||||
<div class="flex items-center text-black">
|
||||
<Avatar src={comment.avatar} name={comment.name} width="w-7" height="h-7" />
|
||||
<Avatar.Root class="h-7 w-7">
|
||||
<Avatar.Image
|
||||
src={comment.avatar ? comment.avatar : '/logo-192.png'}
|
||||
alt={comment.name ? comment.name : 'User'}
|
||||
/>
|
||||
<Avatar.Fallback>{shortenName(comment.name) || 'U'}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<p class="ml-2 text-sm dark:text-white">{comment.name}</p>
|
||||
<p class="ml-2 text-sm text-gray-500 dark:text-white">
|
||||
{comment.createdAt}
|
||||
|
||||
Reference in New Issue
Block a user