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:
Adeyemi Adetunji
2025-12-02 15:27:41 +01:00
committed by GitHub
co-authored by Rotimi Best
parent c7854efae3
commit 9a5aee1ee0
40 changed files with 573 additions and 276 deletions
+54 -40
View File
@@ -130,10 +130,7 @@ import type { TNewOrganization, TOrganization } from '@db/types';
* @returns Created organization record * @returns Created organization record
*/ */
export const createOrganization = async (data: TNewOrganization) => { export const createOrganization = async (data: TNewOrganization) => {
const [organization] = await db const [organization] = await db.insert(schema.organization).values(data).returning();
.insert(schema.organization)
.values(data)
.returning();
return organization; return organization;
}; };
@@ -242,11 +239,7 @@ export async function createOrganizationWithOwner(
// Business Logic: Check sitename availability // Business Logic: Check sitename availability
const exists = await checkSiteNameExists(input.siteName); const exists = await checkSiteNameExists(input.siteName);
if (exists) { if (exists) {
throw new AppError( throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
`Site name '${input.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409
);
} }
// Business Logic: Create org and member in a transaction // Business Logic: Create org and member in a transaction
@@ -278,11 +271,7 @@ export async function createOrganizationWithOwner(
} catch (error) { } catch (error) {
// Handle database constraint violations // Handle database constraint violations
if (error.code === '23505') { if (error.code === '23505') {
throw new AppError( throw new AppError(`Site name '${input.siteName}' already exists`, ErrorCodes.SITENAME_EXISTS, 409);
`Site name '${input.siteName}' already exists`,
ErrorCodes.SITENAME_EXISTS,
409
);
} }
throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500); throw new AppError('Failed to create organization', ErrorCodes.ORG_CREATE_FAILED, 500);
} }
@@ -312,7 +301,7 @@ export class AppError extends Error {
export const ErrorCodes = { export const ErrorCodes = {
SITENAME_EXISTS: 'SITENAME_EXISTS', SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED', ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND', PROFILE_NOT_FOUND: 'PROFILE_NOT_FOUND'
// ... add more as needed // ... add more as needed
} as const; } as const;
``` ```
@@ -334,19 +323,25 @@ export const handleError = (
console.error('Error in route:', error); console.error('Error in route:', error);
if (error instanceof AppError) { if (error instanceof AppError) {
return c.json({ return c.json(
{
success: false, success: false,
error: error.message, error: error.message,
code: error.code, code: error.code,
field: error.field field: error.field
}, error.statusCode); },
error.statusCode
);
} }
return c.json({ return c.json(
{
success: false, success: false,
error: fallbackMessage, error: fallbackMessage,
code: fallbackCode code: fallbackCode
}, 500); },
500
);
}; };
``` ```
@@ -467,7 +462,7 @@ All API responses should follow a consistent structure:
Use semantically correct HTTP status codes: Use semantically correct HTTP status codes:
| Code | Meaning | Usage | | Code | Meaning | Usage |
|------|---------|-------| | --------------------------- | ------------ | -------------------------------------------------------- |
| `200 OK` | Success | Successful GET, PUT, PATCH requests | | `200 OK` | Success | Successful GET, PUT, PATCH requests |
| `201 Created` | Created | Successful POST creating a new resource | | `201 Created` | Created | Successful POST creating a new resource |
| `204 No Content` | Success | Successful DELETE or update with no response body | | `204 No Content` | Success | Successful DELETE or update with no response body |
@@ -552,11 +547,10 @@ export * from './course';
import { ZCourseClone } from '@cio/utils/validation/course'; import { ZCourseClone } from '@cio/utils/validation/course';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const cloneRouter = new Hono() export const cloneRouter = new Hono().post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
.post('/', authMiddleware, zValidator('json', ZCourseClone), async (c) => {
const data = c.req.valid('json'); // Fully typed! const data = c.req.valid('json'); // Fully typed!
// ... route handler // ... route handler
}); });
``` ```
**Using in Frontend Forms**: **Using in Frontend Forms**:
@@ -618,11 +612,11 @@ This allows different messages for shared field names (e.g., "Course title is re
```typescript ```typescript
// With entity name (entity-specific messages) // With entity name (entity-specific messages)
mapZodErrorsToTranslations(error, 'course') // "Course title is required" mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
mapZodErrorsToTranslations(error, 'lesson') // "Lesson title is required" mapZodErrorsToTranslations(error, 'lesson'); // "Lesson title is required"
// Without entity name (generic messages) // Without entity name (generic messages)
mapZodErrorsToTranslations(error) // "Title is required" mapZodErrorsToTranslations(error); // "Title is required"
``` ```
**Key Principles**: **Key Principles**:
@@ -843,8 +837,11 @@ import { createOrganizationWithOwner } from '@api/services/organization';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { ZCreateOrg } from '@cio/utils/validation/organization'; import { ZCreateOrg } from '@cio/utils/validation/organization';
export const organizationRouter = new Hono() export const organizationRouter = new Hono().post(
.post('/create', authMiddleware, zValidator('json', ZCreateOrg), async (c) => { '/create',
authMiddleware,
zValidator('json', ZCreateOrg),
async (c) => {
try { try {
const user = c.get('user'); const user = c.get('user');
const body = c.req.valid('json'); const body = c.req.valid('json');
@@ -855,7 +852,8 @@ export const organizationRouter = new Hono()
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to create organization'); return handleError(c, error, 'Failed to create organization');
} }
}); }
);
``` ```
**Key Points:** **Key Points:**
@@ -967,8 +965,7 @@ import { authMiddleware } from '@api/middlewares/auth';
import { handleError } from '@api/utils/errors'; import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator'; import { zValidator } from '@hono/zod-validator';
export const accountRouter = new Hono() export const accountRouter = new Hono().put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
.put('/user', authMiddleware, zValidator('json', ZUpdateProfile), async (c) => {
try { try {
const user = c.get('user'); const user = c.get('user');
const validatedData = c.req.valid('json'); const validatedData = c.req.valid('json');
@@ -978,7 +975,7 @@ export const accountRouter = new Hono()
} catch (error) { } catch (error) {
return handleError(c, error, 'Failed to update profile'); return handleError(c, error, 'Failed to update profile');
} }
}); });
``` ```
--- ---
@@ -992,13 +989,20 @@ export * from './account';
// apps/api/src/app.ts // apps/api/src/app.ts
import { accountRouter } from '@api/routes/account'; import { accountRouter } from '@api/routes/account';
export const app = new Hono() export const app = new Hono().route('/account', accountRouter); // Route available at PUT /account/user
.route('/account', accountRouter); // Route available at PUT /account/user
``` ```
--- ---
### Step 5: Use in Frontend ### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
### Step 6: Use in Frontend
**Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts` **Location**: `apps/dashboard/src/lib/features/{domain}/api/{entity}.svelte.ts`
@@ -1642,14 +1646,22 @@ export const app = new Hono()
.route('/account', accountRouter) .route('/account', accountRouter)
.route('/onboarding', onboardingRouter) // Add new route .route('/onboarding', onboardingRouter) // Add new route
.route('/course', courseRouter) .route('/course', courseRouter)
.route('/mail', mailRouter) .route('/mail', mailRouter);
// ... error handling ... // ... error handling ...
``` ```
--- ---
## Step 5: Update Frontend ### Step 5: Run the api build command (at the root directory)
```
sudo pnpm --filter @cio/api build
```
---
## Step 6: Update Frontend
**Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar) **Location**: `apps/dashboard/src/lib/utils/services/api.ts` (or similar)
@@ -2062,7 +2074,7 @@ const result = await OrgPlanApiServer.createOrgPlan(planData);
- **Use entity-specific translations** when multiple entities share field names - **Use entity-specific translations** when multiple entities share field names
```typescript ```typescript
mapZodErrorsToTranslations(error, 'course') // "Course title is required" mapZodErrorsToTranslations(error, 'course'); // "Course title is required"
``` ```
- **Provide generic translations** as fallbacks for shared fields (email, password) - **Provide generic translations** as fallbacks for shared fields (email, password)
@@ -2255,7 +2267,9 @@ describe('Onboarding Routes', () => {
// Create first, then attempt duplicate // Create first, then attempt duplicate
const res = await app.request('/onboarding/create-org', { const res = await app.request('/onboarding/create-org', {
method: 'POST', method: 'POST',
body: JSON.stringify({ /* duplicate data */ }) body: JSON.stringify({
/* duplicate data */
})
}); });
expect(res.status).toBe(409); expect(res.status).toBe(409);
+12 -9
View File
@@ -1,20 +1,22 @@
import 'dotenv/config'; import 'dotenv/config';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { prettyJSON } from 'hono/pretty-json';
import { secureHeaders } from 'hono/secure-headers';
import { auth } from '@cio/db/auth';
import { Hono } from '@api/utils/hono';
import rateLimiter from '@api/middlewares/rate-limiter';
import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants'; import { API_SERVER_URL, TRUSTED_ORIGINS } from '@api/constants';
import { Hono } from '@api/utils/hono'; // --- routes
import { accountRouter } from '@api/routes/account';
import { auth } from '@cio/db/auth';
import { cors } from 'hono/cors';
import { courseRouter } from '@api/routes/course';
import { logger } from 'hono/logger';
import { mailRouter } from '@api/routes/mail'; import { mailRouter } from '@api/routes/mail';
import { courseRouter } from '@api/routes/course';
import { accountRouter } from '@api/routes/account';
import { dashAnalyticsRouter } from '@api/routes/dash';
import { mediaRouter } from '@api/routes/media'; import { mediaRouter } from '@api/routes/media';
import { onboardingRouter } from '@api/routes/onboarding'; import { onboardingRouter } from '@api/routes/onboarding';
import { organizationRouter } from '@api/routes/organization'; import { organizationRouter } from '@api/routes/organization';
import { prettyJSON } from 'hono/pretty-json';
import rateLimiter from '@api/middlewares/rate-limiter';
import { secureHeaders } from 'hono/secure-headers';
// Create Hono app with chaining for RPC support // Create Hono app with chaining for RPC support
export const app = new Hono() export const app = new Hono()
@@ -74,6 +76,7 @@ export const app = new Hono()
.route('/mail', mailRouter) .route('/mail', mailRouter)
.route('/media', mediaRouter) .route('/media', mediaRouter)
.route('/organization', organizationRouter) .route('/organization', organizationRouter)
.route('/dash', dashAnalyticsRouter)
// Error handling // Error handling
.onError((err, c) => { .onError((err, c) => {
+1
View File
@@ -0,0 +1 @@
export * from './stats';
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from '@api/utils/hono';
import { ZDashStats } from '@cio/utils/validation/dash';
import { authMiddleware } from '@api/middlewares/auth';
import { getOrganisationAnalytics } from '@api/services/dash';
import { handleError } from '@api/utils/errors';
import { zValidator } from '@hono/zod-validator';
export const dashAnalyticsRouter = new Hono().post(
'/stats',
authMiddleware,
zValidator('json', ZDashStats),
async (c) => {
try {
const { orgId, siteName } = c.req.valid('json');
const result = await getOrganisationAnalytics(orgId, siteName);
return c.json({ success: true, data: result }, 201);
} catch (error) {
return handleError(c, error, 'Failed to load organisation analytics');
}
}
);
+60
View File
@@ -0,0 +1,60 @@
import { AppError, ErrorCodes } from '@api/utils/errors';
import { getCourseStats, getDashOrgStats, getRecentEnrollments } from '@cio/db/queries/dash';
import { OrganisationAnalytics } from '@api/types';
import { getOrgIdBySiteName } from '@db/queries';
export async function getOrganisationAnalytics(orgId?: string, siteName?: string): Promise<OrganisationAnalytics> {
const analytics: OrganisationAnalytics = {
revenue: 0,
numberOfCourses: 0,
totalStudents: 0,
topCourses: [],
enrollments: []
};
let resolvedOrgId = orgId;
if (!resolvedOrgId && siteName) {
const [org] = await getOrgIdBySiteName(siteName);
if (org) {
resolvedOrgId = org.id;
} else {
throw new AppError('Organization not found for the given site name', ErrorCodes.ORG_NOT_FOUND, 404);
}
}
try {
const [stats, topCourses, enrollments] = await Promise.all([
getDashOrgStats(resolvedOrgId!),
getCourseStats(resolvedOrgId!),
getRecentEnrollments(resolvedOrgId!)
]);
analytics.revenue = 0;
analytics.numberOfCourses = stats?.[0]?.noOfCourses ?? 0;
analytics.totalStudents = stats?.[0]?.enrolledStudents ?? 0;
analytics.topCourses = topCourses.map((c) => ({
id: c.courseId,
title: c.courseTitle,
enrollments: c.totalStudents,
completion: c.completionPercentage
}));
analytics.enrollments = enrollments.map((e) => ({
id: e.profileId,
avatarUrl: e.avatarUrl,
name: e.fullname,
courseId: e.courseId,
course: e.courseTitle,
date: e.enrolledAt
}));
return analytics;
} catch (error) {
console.error('Failed to load organisation analytics:', error);
throw new AppError('Failed to load organisation analytics', ErrorCodes.ORG_ANALYTICS_FETCH_FAILED, 500);
}
}
+1
View File
@@ -1,3 +1,4 @@
export * from './account'; export * from './account';
export * from './media'; export * from './media';
export * from './onboarding'; export * from './onboarding';
export * from './dash';
+23
View File
@@ -0,0 +1,23 @@
export interface TopCourse {
id: string;
title: string;
enrollments: number;
completion: number;
}
export interface Enrollment {
id: string;
avatarUrl: string | null;
name: string;
courseId: string;
course: string;
date: string;
}
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: TopCourse[];
enrollments: Enrollment[];
}
+1
View File
@@ -1,2 +1,3 @@
// Re-export all types // Re-export all types
export * from './database'; export * from './database';
export * from './dash';
+4
View File
@@ -28,6 +28,10 @@ export interface ErrorResponse {
// Error codes constants for consistency // Error codes constants for consistency
export const ErrorCodes = { export const ErrorCodes = {
// Dashboard Analytics errors
ORG_ANALYTICS_FETCH_FAILED: 'ORG_ANALYTICS_FETCH_FAILED',
NO_ORG_ID_PROVIDED: 'NO_ORG_ID_PROVIDED',
// Organization errors // Organization errors
SITENAME_EXISTS: 'SITENAME_EXISTS', SITENAME_EXISTS: 'SITENAME_EXISTS',
ORG_CREATE_FAILED: 'ORG_CREATE_FAILED', ORG_CREATE_FAILED: 'ORG_CREATE_FAILED',
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { UserAnalytics } from '$lib/utils/types/analytics'; import type { UserAnalytics } from '$lib/utils/types/analytics';
@@ -15,7 +15,13 @@
<div class="rounded-md border p-5 dark:border-neutral-600"> <div class="rounded-md border p-5 dark:border-neutral-600">
<div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row"> <div class="flex w-full flex-col items-center justify-start gap-4 text-start md:flex-row">
<Avatar src={user.avatarUrl} name={user.fullName} width="w-16" height="h-16" /> <Avatar.Root class="h-16 w-16">
<Avatar.Image
src={user.avatarUrl ? user.avatarUrl : '/logo-192.png'}
alt={user.fullName ? user.fullName : 'User'}
/>
<Avatar.Fallback>{user.fullName ? user.fullName : 'User'}</Avatar.Fallback>
</Avatar.Root>
<div class="flex flex-col space-y-2"> <div class="flex flex-col space-y-2">
<p class="text-center text-2xl md:text-left dark:text-white"> <p class="text-center text-2xl md:text-left dark:text-white">
{user.fullName} {user.fullName}
@@ -1,21 +0,0 @@
<script lang="ts">
interface Props {
name?: string;
src?: string;
className?: string;
shape?: string;
width?: string;
height?: string;
}
let {
name = '',
src = '',
className = '',
shape = 'rounded-full',
width = 'w-10',
height = 'h-10'
}: Props = $props();
</script>
<img alt={name} {src} class="{className} {width} {height} {shape}" />
@@ -8,8 +8,9 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import EmptyState from './EmptyState.svelte'; import EmptyState from './EmptyState.svelte';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import type { StudentOverview } from '$lib/utils/types/analytics'; import type { StudentOverview } from '$lib/utils/types/analytics';
import { shortenName } from '$lib/utils/functions/string';
interface Props { interface Props {
students?: StudentOverview[]; students?: StudentOverview[];
@@ -97,7 +98,13 @@
class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700" class="sticky left-0 z-10 min-w-[200px] whitespace-nowrap bg-white px-4 py-3 transition-colors group-hover:bg-gray-50 dark:bg-neutral-800 dark:group-hover:bg-gray-700"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
<Avatar src={student.profile.avatar_url} name={student.profile.fullname} width="w-8" height="h-8" /> <Avatar.Root class="size-8">
<Avatar.Image
src={student.profile.avatar_url ? student.profile.avatar_url : '/logo-192.png'}
alt={student.profile.fullname ? student.profile.fullname : 'Student'}
/>
<Avatar.Fallback>{shortenName(student.profile.fullname) || 'S'}</Avatar.Fallback>
</Avatar.Root>
<div class="min-w-0 flex-1"> <div class="min-w-0 flex-1">
<p class="truncate font-medium text-gray-900 dark:text-white"> <p class="truncate font-medium text-gray-900 dark:text-white">
{student.profile.fullname} {student.profile.fullname}
@@ -5,8 +5,9 @@
import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js'; import type { PostgrestSingleResponse, RealtimePostgresChangesPayload } from '@supabase/supabase-js';
import { group } from '$lib/components/Course/store'; import { group } from '$lib/components/Course/store';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import TextArea from '$lib/components/Form/TextArea.svelte'; import TextArea from '$lib/components/Form/TextArea.svelte';
import { shortenName } from '$lib/utils/functions/string';
import DeleteModal from '$lib/components/Modal/DeleteModal.svelte'; import DeleteModal from '$lib/components/Modal/DeleteModal.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons'; import { lesson, lessonCommentsChannel } from '$lib/components/Course/components/Lesson/store/lessons';
@@ -62,7 +63,7 @@
id: 0, id: 0,
comment: comment, comment: comment,
name: $t('course.navItem.lessons.comments.you'), name: $t('course.navItem.lessons.comments.you'),
avatar: $profile.avatar_url, avatar: $profile.avatarUrl,
commentAt: new Date(), commentAt: new Date(),
groupmember_id: groupmember.id groupmember_id: groupmember.id
}, },
@@ -270,7 +271,13 @@
</div> </div>
<div> <div>
<div class="flex h-full items-start gap-3"> <div class="flex h-full items-start gap-3">
<Avatar src={$profile.avatar_url} name={$profile.fullname} width="w-8" height="h-8" className="mt-2" /> <Avatar.Root class="mt-2 h-8 w-8">
<Avatar.Image
src={$profile.avatarUrl ? $profile.avatarUrl : '/logo-192.png'}
alt={$profile.fullname ? $profile.fullname : 'User'}
/>
<Avatar.Fallback>{shortenName($profile.fullname) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="h-full w-full"> <div class="h-full w-full">
<TextArea <TextArea
label={$t('course.navItem.lessons.comments.text_area_title')} label={$t('course.navItem.lessons.comments.text_area_title')}
@@ -293,7 +300,13 @@
<div class="my-10"> <div class="my-10">
{#each comments as commentItem} {#each comments as commentItem}
<div class="mt-2 flex items-start gap-3 pb-2"> <div class="mt-2 flex items-start gap-3 pb-2">
<Avatar src={commentItem.avatar} name={commentItem.name} width="w-8" height="h-8" /> <Avatar.Root class="h-8 w-8">
<Avatar.Image
src={commentItem.avatar ? commentItem.avatar : '/logo-192.png'}
alt={commentItem.name ? commentItem.name : 'User'}
/>
<Avatar.Fallback>{shortenName(commentItem.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
<div class="w-full rounded-md border px-4 pb-4 pt-2 dark:border-neutral-700"> <div class="w-full rounded-md border px-4 pb-4 pt-2 dark:border-neutral-700">
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
@@ -349,7 +362,7 @@
/> />
</div> </div>
{:else} {:else}
<article class="prose max-w-[300px] sm:prose-sm dark:text-white"> <article class="prose sm:prose-sm max-w-[300px] dark:text-white">
{commentItem.comment} {commentItem.comment}
</article> </article>
{/if} {/if}
@@ -7,8 +7,9 @@
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { VARIANTS } from '$lib/components/PrimaryButton/constants'; import { VARIANTS } from '$lib/components/PrimaryButton/constants';
import ReviewFormEditor from './ReviewFormEditor.svelte'; import ReviewFormEditor from './ReviewFormEditor.svelte';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import { processErrors } from '$lib/utils/functions/validator'; import { processErrors } from '$lib/utils/functions/validator';
import { shortenName } from '$lib/utils/functions/string';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import type { Course } from '$lib/utils/types'; import type { Course } from '$lib/utils/types';
@@ -98,7 +99,13 @@
{#if review.id !== reviewToExpand} {#if review.id !== reviewToExpand}
<!-- the headers --> <!-- the headers -->
<div class="flex w-full items-center justify-between"> <div class="flex w-full items-center justify-between">
<Avatar src={review.avatar_url} name={review.name} className="mt-1" /> <Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Reviewer'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'R'}</Avatar.Fallback>
</Avatar.Root>
<p class="text-sm">{review.name}</p> <p class="text-sm">{review.name}</p>
<IconButton value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}> <IconButton value="expand" onClick={() => onExpand(review.id)} size={$isMobile ? 'large' : 'small'}>
@@ -21,9 +21,10 @@
import Chip from '../Chip/index.svelte'; import Chip from '../Chip/index.svelte';
import Modal from '../Modal/index.svelte'; import Modal from '../Modal/index.svelte';
import ImageRenderer from '../Org/ImageRenderer.svelte'; import ImageRenderer from '../Org/ImageRenderer.svelte';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import PricingSection from './components/PricingSection.svelte'; import PricingSection from './components/PricingSection.svelte';
import { PoweredBy } from '$lib/features/ui'; import { PoweredBy } from '$lib/features/ui';
import { shortenName } from '$lib/utils/functions/string';
import SectionsDisplay from './components/SectionsDisplay.svelte'; import SectionsDisplay from './components/SectionsDisplay.svelte';
import UploadWidget from '$lib/components/UploadWidget/index.svelte'; import UploadWidget from '$lib/components/UploadWidget/index.svelte';
import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte'; import HtmlRender from '$lib/components/HTMLRender/HTMLRender.svelte';
@@ -332,7 +333,13 @@
<div class="item-start my-2 flex w-2/4 flex-row"> <div class="item-start my-2 flex w-2/4 flex-row">
<!-- image container --> <!-- image container -->
{#if review.avatar_url} {#if review.avatar_url}
<Avatar src={review.avatar_url} name="Avatar" className="mt-1" /> <Avatar.Root class="mt-1 size-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Avatar'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
</Avatar.Root>
{/if} {/if}
<!-- profile content --> <!-- profile content -->
@@ -401,7 +408,13 @@
<div class="item-start my-2 flex w-full flex-row"> <div class="item-start my-2 flex w-full flex-row">
<!-- image container --> <!-- image container -->
{#if review.avatar_url} {#if review.avatar_url}
<Avatar src={review.avatar_url} name={review.name} className="mt-1" /> <Avatar.Root class="mt-1 h-10 w-10">
<Avatar.Image
src={review.avatar_url ? review.avatar_url : '/logo-192.png'}
alt={review.name ? review.name : 'Avatar'}
/>
<Avatar.Fallback>{shortenName(review.name) || 'AV'}</Avatar.Fallback>
</Avatar.Root>
{/if} {/if}
<!-- profile content --> <!-- profile content -->
@@ -1,39 +1,30 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import BellIcon from '@lucide/svelte/icons/bell'; import BellIcon from '@lucide/svelte/icons/bell';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import TextChip from '$lib/components/Chip/Text.svelte';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { shortenName } from '$lib/utils/functions/string';
interface Props { interface Props {
navClass?: string; navClass?: string;
} }
let { navClass = '' }: Props = $props(); let { navClass = '' }: Props = $props();
function shortenName(name: string) {
return name?.substring(0, 2)?.toUpperCase() || '';
}
</script> </script>
<nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6"> <nav class="{navClass} bg-primary-700 flex h-[48px] w-full p-2 md:px-6">
<ul class="flex w-full items-center"> <ul class="flex w-full items-center">
<div class=""> <div class="">
<a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg"> <a href={page.url.pathname} title={$t('navigation.goto_home')} id="logo" class="flex items-center text-lg">
{#if $currentOrg.avatar_url} <Avatar.Root class="mr-2 h-7 w-7 rounded-md">
<Avatar <Avatar.Image
src={$currentOrg.avatar_url} src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name} alt={$currentOrg.name ? $currentOrg.name : 'Organization'}
shape="rounded-md"
width="w-7"
height="h-7"
className="mr-2"
/> />
{:else} <Avatar.Fallback>{shortenName($currentOrg.name)}</Avatar.Fallback>
<TextChip value={shortenName($currentOrg.name)} className="bg-primary-200 mr-2 dark:text-black" /> </Avatar.Root>
{/if}
<span class="line-clamp-1"> <span class="line-clamp-1">
{$currentOrg.name} {$currentOrg.name}
</span> </span>
@@ -10,8 +10,9 @@
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
import { UpgradeBanner } from '$lib/features/ui'; import { UpgradeBanner } from '$lib/features/ui';
import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org'; import { currentOrg, currentOrgMaxAudience } from '$lib/utils/store/org';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { shortenName } from '$lib/utils/functions/string';
$effect(() => { $effect(() => {
orgApi.getOrgAudience($currentOrg.id); orgApi.getOrgAudience($currentOrg.id);
@@ -81,7 +82,13 @@
href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`} href={`${pageStore.url.href}/${row.id}/${$currentOrg.id}`}
class="text-primary-700 flex items-center gap-2 hover:underline" class="text-primary-700 flex items-center gap-2 hover:underline"
> >
<Avatar src={row.avatar_url} width="w-5" height="h-5" /> <Avatar.Root class="h-5 w-5">
<Avatar.Image
src={row.avatar_url ? row.avatar_url : '/logo-192.png'}
alt={row.name ? row.name : 'User'}
/>
<Avatar.Fallback>{shortenName(row.name) || 'U'}</Avatar.Fallback>
</Avatar.Root>
{row.name} {row.name}
</a> </a>
</Table.Cell> </Table.Cell>
@@ -0,0 +1,34 @@
import { BaseApi, classroomio } from '$lib/utils/services/api';
import type { DashStatsSuccess } from '$lib/features/org/utils/types';
/**
* API class for organization dashboard analytics
*/
class DashStatApi extends BaseApi {
stats = $state<DashStatsSuccess['data']>({
enrollments: [],
numberOfCourses: 0,
revenue: 0,
totalStudents: 0,
topCourses: []
});
/**
* Fetches dashboard statistics for an organization
* @param orgId Optional organization ID
* @param siteName Optional site name
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
*/
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
await this.execute<typeof classroomio.dash.stats.$post>({
requestFn: () => classroomio.dash.stats.$post({ json: { orgId, siteName } }),
logContext: 'fetching organization stats',
onSuccess: (response) => {
this.stats = response.data;
}
});
}
}
export const dashStatApi = new DashStatApi();
@@ -1,2 +1,3 @@
export * from './org.svelte'; export * from './org.svelte';
export * from './org-plan.svelte'; export * from './org-plan.svelte';
export * from './dash-stats.svelte';
@@ -33,6 +33,11 @@ export type CoursesByOrganizationSiteNameSuccess = Extract<
export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data']; export type CoursesByOrganizationSiteName = CoursesByOrganizationSiteNameSuccess['data'];
// dashboard analytics types
export type GetDashStatsResponse = typeof classroomio.dash.stats.$post;
export type DashStatsResponse = InferResponseType<GetDashStatsResponse> | null;
export type DashStatsSuccess = Extract<InferResponseType<GetDashStatsResponse>, { success: true }>;
export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post']; export type InviteTeamResponse = (typeof classroomio.organization)[':orgId']['team']['invite']['$post'];
export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>; export type InviteTeamSuccess = Extract<InferResponseType<InviteTeamResponse>, { success: true }>;
export type InviteTeamData = InviteTeamSuccess['data']; export type InviteTeamData = InviteTeamSuccess['data'];
@@ -47,7 +47,7 @@
</svg> </svg>
<!-- Center text --> <!-- Center text -->
<div class="absolute inset-0 flex flex-col items-center justify-center"> <div class="absolute inset-0 flex flex-col items-center justify-center">
<p class="{size === 'small' ? 'text-[10px]' : 'text-sm'} font-bold">{progressPercentage}%</p> <p class={size === 'small' ? 'text-[10px]' : 'text-sm'}>{progressPercentage}%</p>
</div> </div>
{/if} {/if}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import Avatar from '$lib/components/Avatar/index.svelte'; import * as Avatar from '@cio/ui/base/avatar';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org'; import { currentOrg } from '$lib/utils/store/org';
import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte'; import GoogleIconColored from '$lib/components/Icons/GoogleIconColored.svelte';
@@ -68,14 +68,14 @@
<Card.Root class="ui:w-full max-w-[400px]"> <Card.Root class="ui:w-full max-w-[400px]">
{#if !showOnlyContent || showLogo} {#if !showOnlyContent || showLogo}
<Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4"> <Card.Header class="ui:flex ui:flex-col ui:items-center ui:gap-4">
<Avatar <Avatar.Root>
<Avatar.Image
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'} src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'} alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="max-h-10"
className="mr-2"
/> />
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
</Avatar.Root>
<a href="/"> <a href="/">
<Card.Title class="ui:text-2xl ui:font-bold"> <Card.Title class="ui:text-2xl ui:font-bold">
{isLogin ? $t('login.welcome') : $t('login.create_account')} {isLogin ? $t('login.welcome') : $t('login.create_account')}
@@ -8,7 +8,7 @@
import * as DropdownMenu from '@cio/ui/base/dropdown-menu'; import * as DropdownMenu from '@cio/ui/base/dropdown-menu';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'; import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import { getShortOrgName } from '$lib/utils/functions/org'; import { shortenName } from '$lib/utils/functions/string';
import type { AccountOrg } from '$lib/features/app/types'; import type { AccountOrg } from '$lib/features/app/types';
import { setTheme } from '$lib/utils/functions/theme'; import { setTheme } from '$lib/utils/functions/theme';
@@ -44,7 +44,7 @@
{#if $currentOrg.name} {#if $currentOrg.name}
<Avatar.Root class="rounded-md! size-6! flex items-center justify-center"> <Avatar.Root class="rounded-md! size-6! flex items-center justify-center">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} /> <Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-md! text-xs">{getShortOrgName($currentOrg.name)}</Avatar.Fallback> <Avatar.Fallback class="rounded-md! text-xs">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span> <span class="hidden truncate text-sm font-medium md:block">{$currentOrg.name}</span>
<ChevronDownIcon class="ml-auto hidden size-4 md:block" /> <ChevronDownIcon class="ml-auto hidden size-4 md:block" />
@@ -66,7 +66,7 @@
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2"> <DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg"> <Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
<Avatar.Image src={org.avatarUrl} alt={org.name} /> <Avatar.Image src={org.avatarUrl} alt={org.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback> <Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
{org.name} {org.name}
@@ -100,7 +100,7 @@
{#if $currentOrg.name} {#if $currentOrg.name}
<Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg"> <Avatar.Root class="ui:flex ui:size-8 ui:items-center ui:justify-center ui:rounded-lg">
<Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} /> <Avatar.Image src={$currentOrg.avatarUrl} alt={$currentOrg.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName($currentOrg.name)}</Avatar.Fallback> <Avatar.Fallback class="rounded-lg">{shortenName($currentOrg.name)}</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight"> <div class="grid flex-1 text-left text-sm leading-tight">
@@ -132,7 +132,7 @@
<DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2"> <DropdownMenu.Item onSelect={() => onClick(org)} class="gap-2 p-2">
<Avatar.Root class="flex size-8 items-center justify-center rounded-lg"> <Avatar.Root class="flex size-8 items-center justify-center rounded-lg">
<Avatar.Image src={org.avatarUrl} alt={org.name} /> <Avatar.Image src={org.avatarUrl} alt={org.name} />
<Avatar.Fallback class="rounded-lg">{getShortOrgName(org.name)}</Avatar.Fallback> <Avatar.Fallback class="rounded-lg">{shortenName(org.name)}</Avatar.Fallback>
</Avatar.Root> </Avatar.Root>
{org.name} {org.name}
@@ -23,7 +23,3 @@ export function openUpgradeModal() {
goto(resolve(window.location.pathname + `?${searchParams}`, {})); goto(resolve(window.location.pathname + `?${searchParams}`, {}));
} }
export function getShortOrgName(name: string) {
return name?.substring(0, 2)?.toUpperCase() || '';
}
@@ -1,3 +1,7 @@
export function capitalizeFirstLetter(val: string): string { export function capitalizeFirstLetter(val: string): string {
return val.charAt(0).toUpperCase() + val.slice(1); return val.charAt(0).toUpperCase() + val.slice(1);
} }
export function shortenName(name: string | null | undefined): string {
return name?.substring(0, 2)?.toUpperCase() || '';
}
@@ -119,26 +119,6 @@ export interface UserAnalytics {
overallAverageGrade: number; overallAverageGrade: number;
} }
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: {
id: string;
title: string;
enrollments: number;
completion: number;
}[];
enrollments: {
id: string;
avatarUrl: string;
name: string;
courseId: string;
course: string;
date: string;
}[];
}
export interface UserCourseAnalytics { export interface UserCourseAnalytics {
user: User; user: User;
averageGrade: number; averageGrade: number;
@@ -4,7 +4,7 @@
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import * as Card from '@cio/ui/base/card'; import * as Card from '@cio/ui/base/card';
import { currentOrg } from '$lib/utils/store/org'; 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')); const errorCode = $derived(new URLSearchParams(page.url.search).get('error'));
</script> </script>
@@ -15,14 +15,13 @@
<AuthUI isLogin={false} showOnlyContent={true}> <AuthUI isLogin={false} showOnlyContent={true}>
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col items-center gap-4">
<Avatar <Avatar.Root>
<Avatar.Image
src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'} src={$currentOrg.avatarUrl ? $currentOrg.avatarUrl : '/logo-192.png'}
name={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'} alt={$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}
shape="rounded-md"
width="w-10"
height="max-h-10"
className="mr-2"
/> />
<Avatar.Fallback>{$currentOrg.name ? $currentOrg.name : 'ClassroomIO'}</Avatar.Fallback>
</Avatar.Root>
<p class="text-xl font-semibold">Something went wrong</p> <p class="text-xl font-semibold">Something went wrong</p>
@@ -10,7 +10,7 @@
import SearchIcon from '@lucide/svelte/icons/search'; import SearchIcon from '@lucide/svelte/icons/search';
import TextChip from '$lib/components/Chip/Text.svelte'; 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 { ComingSoon } from '$lib/features/ui';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { RoleBasedSecurity } from '$lib/features/ui'; import { RoleBasedSecurity } from '$lib/features/ui';
@@ -21,6 +21,7 @@
import type { GroupPerson } from '$lib/utils/types'; import type { GroupPerson } from '$lib/utils/types';
import { group } from '$lib/components/Course/store'; import { group } from '$lib/components/Course/store';
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { shortenName } from '$lib/utils/functions/string';
import Select from '$lib/components/Form/Select.svelte'; import Select from '$lib/components/Form/Select.svelte';
import { IconButton } from '$lib/components/IconButton'; import { IconButton } from '$lib/components/IconButton';
import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles'; import { ROLE_LABEL, ROLES } from '$lib/utils/constants/roles';
@@ -133,13 +134,13 @@
<Table.Cell class="w-4/6 md:w-3/6"> <Table.Cell class="w-4/6 md:w-3/6">
{#if person.profile} {#if person.profile}
<div class="flex items-start lg:items-center"> <div class="flex items-start lg:items-center">
<Avatar <Avatar.Root class="mr-3">
src={person.profile.avatar_url} <Avatar.Image
name={person.profile.fullname} src={person.profile.avatar_url ? person.profile.avatar_url : '/logo-192.png'}
width="w-8" alt={person.profile.fullname ? person.profile.fullname : 'User'}
height="h-8"
className="mr-3"
/> />
<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="flex flex-col items-start lg:flex-row lg:items-center">
<div class="mr-2"> <div class="mr-2">
<p class="text-base font-normal dark:text-white"> <p class="text-base font-normal dark:text-white">
@@ -173,7 +174,7 @@
{:else} {:else}
<div class="flex w-2/4 items-start lg:items-center"> <div class="flex w-2/4 items-start lg:items-center">
<TextChip <TextChip
value={person.email.substring(0, 2).toUpperCase()} value={shortenName(person.email)}
className="bg-primary-200 text-black font-semibold text-xs mr-3" className="bg-primary-200 text-black font-semibold text-xs mr-3"
shape="rounded-full" shape="rounded-full"
/> />
@@ -21,8 +21,9 @@
import Vote from '$lib/components/Vote/index.svelte'; import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton'; 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 TextField from '$lib/components/Form/TextField.svelte';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte'; import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import CircleCheckIcon from '$lib/components/Icons/CircleCheckIcon.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"> <div class="border-1 border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none"> <header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline"> <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 dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white"> <p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt} {question.createdAt}
@@ -490,7 +497,13 @@
<div class="border-1 border-gray w-full rounded-lg border"> <div class="border-1 border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none"> <header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black"> <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 dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white"> <p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt} {comment.createdAt}
@@ -7,7 +7,6 @@ const APP_SUBDOMAINS = env.PRIVATE_APP_SUBDOMAINS?.split(',') || ['app'];
export const load = async ({ params, url }) => { export const load = async ({ params, url }) => {
const subdomain = getSubdomain(url); const subdomain = getSubdomain(url);
const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain); const isOrgSite = subdomain && !APP_SUBDOMAINS.includes(subdomain);
// If this is LMS but user is on org site, redirect to LMS // If this is LMS but user is on org site, redirect to LMS
if (isOrgSite) { if (isOrgSite) {
redirect(307, `/lms`); redirect(307, `/lms`);
@@ -10,14 +10,13 @@
import { t } from '$lib/utils/functions/translations'; import { t } from '$lib/utils/functions/translations';
import { calDateDiff } from '$lib/utils/functions/date'; import { calDateDiff } from '$lib/utils/functions/date';
import { getGreeting } from '$lib/utils/functions/date'; import { getGreeting } from '$lib/utils/functions/date';
import { snackbar } from '$lib/components/Snackbar/store'; import { currentOrgPath } from '$lib/utils/store/org';
import { getAccessToken } from '$lib/utils/functions/supabase'; import { dashStatApi } from '$lib/features/org/api';
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
import type { OrganisationAnalytics } from '$lib/utils/types/analytics';
import { CreateCourseButton } from '$lib/features/course/components'; 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 { ActivityCard } from '$lib/components/Analytics';
import { shortenName } from '$lib/utils/functions/string';
import Progress from '$lib/components/Progress/index.svelte'; import Progress from '$lib/components/Progress/index.svelte';
import { Button } from '@cio/ui/base/button'; import { Button } from '@cio/ui/base/button';
import { WelcomeModal } from '$lib/features/onboarding/components'; import { WelcomeModal } from '$lib/features/onboarding/components';
@@ -25,60 +24,33 @@
import * as Page from '@cio/ui/base/page'; import * as Page from '@cio/ui/base/page';
import { Empty } from '@cio/ui/custom/empty'; import { Empty } from '@cio/ui/custom/empty';
let dashAnalytics: OrganisationAnalytics | undefined = $state(); const { data } = $props();
let hasFetched = $state(false);
async function fetchDashAnalytics(orgId: string) { const { enrollments, numberOfCourses, revenue, totalStudents, topCourses } = $derived(dashStatApi.stats);
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');
}
}
$effect(() => { $effect(() => {
fetchDashAnalytics($currentOrg.id); dashStatApi.fetchOrgStats({ siteName: data.orgName });
}); });
let cards = $derived([ let cards = $derived([
{ {
icon: DollarSignIcon, icon: DollarSignIcon,
title: `${$t('dashboard.revenue')} ($)`, title: `${$t('dashboard.revenue')} ($)`,
percentage: dashAnalytics?.revenue ?? 0, percentage: revenue ?? 0,
description: $t('dashboard.revenue_description'), description: $t('dashboard.revenue_description'),
hidePercentage: true hidePercentage: true
}, },
{ {
icon: BookIcon, icon: BookIcon,
title: $t('dashboard.no_of_courses'), title: $t('dashboard.no_of_courses'),
percentage: dashAnalytics?.numberOfCourses ?? 0, percentage: numberOfCourses ?? 0,
description: $t('dashboard.no_courses_description'), description: $t('dashboard.no_courses_description'),
hidePercentage: true hidePercentage: true
}, },
{ {
icon: UsersIcon, icon: UsersIcon,
title: $t('dashboard.total_students'), title: $t('dashboard.total_students'),
percentage: dashAnalytics?.totalStudents ?? 0, percentage: totalStudents ?? 0,
description: $t('dashboard.total_students_description'), description: $t('dashboard.total_students_description'),
hidePercentage: true hidePercentage: true
} }
@@ -110,7 +82,7 @@
<div class="mb-10 flex flex-wrap items-start"> <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"> <div class="grid w-full grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{#each cards as card} {#each cards as card}
{#if !dashAnalytics} {#if !revenue && !numberOfCourses && !totalStudents}
<Skeleton style="width: 100%; height: 10rem;" class="rounded-md" /> <Skeleton style="width: 100%; height: 10rem;" class="rounded-md" />
{:else} {:else}
<ActivityCard activity={card} /> <ActivityCard activity={card} />
@@ -123,21 +95,21 @@
<div <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" 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')} {$t('dashboard.top_courses')}
</h3> </h3>
<div class="h-full space-y-6"> <div class="h-full space-y-6">
{#if !dashAnalytics} {#if !topCourses}
{#each Array(5) as _} {#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" /> <Skeleton class="h-10 w-full rounded-md" />
{/each} {/each}
{:else} {:else}
{#each dashAnalytics.topCourses as course} {#each topCourses as course}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="w-4/6 space-y-1"> <div class="w-4/6 space-y-1">
<a href={`/courses/${course.id}`}> <a class="hover:underline" href={`/courses/${course.id}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none"> <p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{course.title} {course.title}
</p> </p>
</a> </a>
@@ -148,7 +120,7 @@
</div> </div>
<div class="ml-auto w-2/6"> <div class="ml-auto w-2/6">
<Progress value={course.completion} /> <Progress value={course.completion} />
<div class="text-sm font-medium"> <div class="text-sm">
{course.completion}% {course.completion}%
{$t('dashboard.completion')} {$t('dashboard.completion')}
</div> </div>
@@ -171,23 +143,29 @@
<div <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" 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')} {$t('dashboard.recent_enrollments')}
</h3> </h3>
<div class="h-full space-y-6"> <div class="h-full space-y-6">
{#if !dashAnalytics} {#if !enrollments}
{#each Array(5) as _} {#each Array(5) as _}
<Skeleton class="h-10 w-full rounded-md" /> <Skeleton class="h-10 w-full rounded-md" />
{/each} {/each}
{:else} {:else}
{#each dashAnalytics.enrollments as enrollment} {#each enrollments as enrollment}
<div class="flex items-center justify-between gap-2"> <div class="flex items-center justify-between gap-2">
<div class="flex items-center 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"> <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"> <p class="text-muted-foreground text-sm">
<span class="italic"> <span class="italic">
{calDateDiff(enrollment.date)} {calDateDiff(enrollment.date)}
@@ -197,8 +175,8 @@
</div> </div>
<div class="w-2/4"> <div class="w-2/4">
<a href={`/courses/${enrollment.courseId}`}> <a class="hover:underline" href={`/courses/${enrollment.courseId}`}>
<p class="line-clamp-2 pb-[0.1rem] text-sm font-medium leading-none"> <p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
{enrollment.course} {enrollment.course}
</p> </p>
</a> </a>
@@ -21,8 +21,9 @@
import { profile } from '$lib/utils/store/user'; import { profile } from '$lib/utils/store/user';
import Vote from '$lib/components/Vote/index.svelte'; import Vote from '$lib/components/Vote/index.svelte';
import { IconButton } from '$lib/components/IconButton'; 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 TextField from '$lib/components/Form/TextField.svelte';
import { shortenName } from '$lib/utils/functions/string';
import TextEditor from '$lib/components/TextEditor/index.svelte'; import TextEditor from '$lib/components/TextEditor/index.svelte';
import PrimaryButton from '$lib/components/PrimaryButton/index.svelte'; import PrimaryButton from '$lib/components/PrimaryButton/index.svelte';
import { CommunityDeleteModal } from '$lib/features/community/components'; import { CommunityDeleteModal } from '$lib/features/community/components';
@@ -454,7 +455,13 @@
<div class="border-gray my-1 rounded-lg border px-1"> <div class="border-gray my-1 rounded-lg border px-1">
<header class="flex items-center justify-between p-2 leading-none"> <header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black no-underline hover:underline"> <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 dark:text-white">{question.author.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white"> <p class="ml-2 text-sm text-gray-500 dark:text-white">
{question.createdAt} {question.createdAt}
@@ -503,7 +510,13 @@
<div class="border-gray w-full rounded-lg border"> <div class="border-gray w-full rounded-lg border">
<header class="flex items-center justify-between p-2 leading-none"> <header class="flex items-center justify-between p-2 leading-none">
<div class="flex items-center text-black"> <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 dark:text-white">{comment.name}</p>
<p class="ml-2 text-sm text-gray-500 dark:text-white"> <p class="ml-2 text-sm text-gray-500 dark:text-white">
{comment.createdAt} {comment.createdAt}
+81
View File
@@ -0,0 +1,81 @@
import {
and,
course,
dashOrgStats,
db,
desc,
eq,
group,
groupmember,
lesson,
lessonCompletion,
organization,
profile,
sql
} from '@db/drizzle';
export function getCourseStats(orgId: string) {
const subquery = db
.select({
courseId: course.id,
courseTitle: course.title,
totalStudents: sql<number>`COUNT(DISTINCT ${groupmember.id})`.as('total_students'),
totalLessons: sql<number>`COUNT(DISTINCT ${lesson.id})`.as('total_lessons'),
completedLessons: sql<number>`
COUNT(DISTINCT ${lessonCompletion.id})
FILTER (WHERE ${lessonCompletion.isComplete} = true)
`.as('completed_lessons')
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.leftJoin(lesson, eq(lesson.courseId, course.id))
.leftJoin(
lessonCompletion,
and(eq(lessonCompletion.lessonId, lesson.id), eq(lessonCompletion.profileId, groupmember.profileId))
)
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(course.id, course.title)
.orderBy(sql`completed_lessons DESC`)
.as('course_stats');
return db
.select({
courseId: subquery.courseId,
courseTitle: subquery.courseTitle,
totalStudents: sql<number>`${subquery.totalStudents}::integer`,
completionPercentage: sql<number>`
CASE
WHEN ${subquery.totalStudents} * ${subquery.totalLessons} = 0 THEN 0
ELSE ROUND((${subquery.completedLessons}::numeric / (${subquery.totalStudents} * ${subquery.totalLessons})) * 100)::integer
END
`
})
.from(subquery)
.orderBy(subquery.completedLessons)
.limit(5);
}
export function getRecentEnrollments(orgId: string) {
return db
.select({
profileId: profile.id,
avatarUrl: profile.avatarUrl,
fullname: profile.fullname,
courseId: course.id,
courseTitle: course.title,
enrolledAt: groupmember.createdAt
})
.from(course)
.innerJoin(group, eq(group.id, course.groupId))
.leftJoin(groupmember, and(eq(groupmember.groupId, group.id), eq(groupmember.roleId, 3)))
.innerJoin(profile, eq(profile.id, groupmember.profileId))
.where(and(eq(course.status, 'ACTIVE'), eq(group.organizationId, orgId)))
.groupBy(profile.id, course.id, course.title, groupmember.createdAt)
.orderBy(desc(groupmember.createdAt))
.limit(5);
}
export function getDashOrgStats(orgId: string) {
return db.select().from(dashOrgStats).where(eq(dashOrgStats.orgId, orgId));
}
+1
View File
@@ -0,0 +1 @@
export * from './dash';
+19
View File
@@ -0,0 +1,19 @@
export interface OrganisationAnalytics {
revenue: number;
numberOfCourses: number;
totalStudents: number;
topCourses: {
id: string;
title: string;
enrollments: number;
completion: number;
}[];
enrollments: {
id: string;
avatarUrl: string;
name: string;
courseId: string;
course: string;
date: string;
}[];
}
@@ -13,6 +13,10 @@ import { and, eq, inArray, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants'; import { ROLE } from '@cio/utils/constants';
import { db } from '@db/drizzle'; import { db } from '@db/drizzle';
export function getOrgIdBySiteName(siteName: string) {
return db.select().from(schema.organization).where(eq(schema.organization.siteName, siteName)).limit(1);
}
export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => { export const getOrganizationByProfileId = async (profileId: string): Promise<OrganizationWithMemberAndPlans[]> => {
const result = await db const result = await db
.select({ .select({
+2
View File
@@ -137,3 +137,5 @@ export type TCourseVersion = (typeof schema.courseVersion.enumValues)[number];
export type TLocale = (typeof schema.locale.enumValues)[number]; export type TLocale = (typeof schema.locale.enumValues)[number];
export type TPlan = (typeof schema.plan.enumValues)[number]; export type TPlan = (typeof schema.plan.enumValues)[number];
export type TDashStats = typeof schema.dashOrgStats.$inferSelect;
@@ -0,0 +1 @@
export * from './stats';
@@ -0,0 +1,12 @@
import * as z from 'zod';
export const ZDashStats = z
.object({
orgId: z.string().optional(),
siteName: z.string().optional()
})
.refine((v) => !!(v.orgId || v.siteName), {
message: 'Either orgId or siteName is required'
});
export type TDashStats = z.infer<typeof ZDashStats>;
+1
View File
@@ -6,3 +6,4 @@ export * from './mail';
export * from './media'; export * from './media';
export * from './onboarding'; export * from './onboarding';
export * from './organization'; export * from './organization';
export * from './dash';