chore: ai, public course and other ui improvements
This commit is contained in:
@@ -14,6 +14,8 @@
|
||||
import ContentCreateModal from '$features/course/components/content/content-create-modal.svelte';
|
||||
import CourseCompletionModal from '$features/course/components/ceritficate/course-completion-modal.svelte';
|
||||
import { AiCourseChat } from '$features/ai-assistant';
|
||||
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { get } from 'svelte/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
@@ -98,6 +100,10 @@
|
||||
}
|
||||
|
||||
hasLoadedSidebarWidth = true;
|
||||
|
||||
if (get(initialChatPrompt)) {
|
||||
openAiAssistant();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
@@ -180,5 +186,5 @@
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
|
||||
<AiCourseChat />
|
||||
<AiCourseChat courseId={data.courseId} />
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex w-[90%] px-4 md:max-w-2xl lg:max-w-3xl">
|
||||
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
|
||||
{#if isLockedForStudent}
|
||||
<Empty
|
||||
title={$t('course.navItem.lessons.content_locked_title')}
|
||||
|
||||
@@ -30,7 +30,7 @@ export const load = async ({ params, url, cookies }) => {
|
||||
if (!orgId) {
|
||||
const apiKeyHeaders = getApiKeyHeaders();
|
||||
const org = await getOrgBySiteName(siteName, apiKeyHeaders);
|
||||
console.log('org', org);
|
||||
|
||||
if (org?.id) {
|
||||
orgId = org.id;
|
||||
|
||||
|
||||
@@ -1,62 +1,5 @@
|
||||
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null,
|
||||
loginActivity: [] as LoginActivityData
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
||||
};
|
||||
}
|
||||
|
||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||
safeServerApi<DashStatsSuccess>(() =>
|
||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||
orgName: params.slug
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,239 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { resolve } from '$app/paths';
|
||||
import CheckCircleIcon from '@lucide/svelte/icons/circle-check';
|
||||
import CircleIcon from '@lucide/svelte/icons/circle';
|
||||
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { CourseCreator } from '@cio/ui/custom/course-creator';
|
||||
import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { setInitialChatModel, setInitialChatPrompt } from '$features/ai-assistant/utils/store';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
|
||||
import type { TCourseType } from '@cio/db/types';
|
||||
import {
|
||||
AGENT_MODELS,
|
||||
DEFAULT_PICKER_MODEL_ID,
|
||||
UI_PICKER_MODEL_IDS,
|
||||
type AgentModelId
|
||||
} from '@cio/utils/agent-models';
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { WelcomeModal } from '$features/onboarding/components';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
type CreatingStep = 'reading' | 'naming' | 'building';
|
||||
|
||||
const { data } = $props();
|
||||
const steps: { key: CreatingStep; label: string }[] = [
|
||||
{ key: 'reading', label: t.get('course.creator.drafting.step_reading') },
|
||||
{ key: 'naming', label: t.get('course.creator.drafting.step_naming') },
|
||||
{ key: 'building', label: t.get('course.creator.drafting.step_building') }
|
||||
];
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const loginActivity = $derived(data.loginActivity);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
let creatingState: 'idle' | 'creating' = $state('idle');
|
||||
let creatingStep: CreatingStep = $state('reading');
|
||||
let draftingPrompt = $state('');
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
function isAgentModelId(value: unknown): value is AgentModelId {
|
||||
return typeof value === 'string' && (UI_PICKER_MODEL_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function stepStatus(key: CreatingStep): 'done' | 'active' | 'pending' {
|
||||
const order: CreatingStep[] = ['reading', 'naming', 'building'];
|
||||
const current = order.indexOf(creatingStep);
|
||||
const target = order.indexOf(key);
|
||||
|
||||
if (target < current) return 'done';
|
||||
if (target === current) return 'active';
|
||||
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
function handleModelChange(model: string) {
|
||||
if (!isAgentModelId(model)) return;
|
||||
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = model;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, model);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function handleLockedModelSelect() {
|
||||
openUpgradeModal();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!$isFreePlan || !paidModelIds.includes(selectedModel)) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = DEFAULT_PICKER_MODEL_ID;
|
||||
|
||||
try {
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
|
||||
if (isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
openUpgradeModal();
|
||||
return;
|
||||
}
|
||||
|
||||
handleModelChange(model);
|
||||
}
|
||||
|
||||
draftingPrompt = prompt;
|
||||
creatingState = 'creating';
|
||||
creatingStep = 'reading';
|
||||
|
||||
await new Promise((r) => setTimeout(r, 700));
|
||||
|
||||
creatingStep = 'naming';
|
||||
const meta = await aiAssistantApi.generateCourseMeta(prompt);
|
||||
const title = meta?.title ?? prompt.slice(0, 80);
|
||||
const description = meta?.description ?? prompt.slice(0, 150);
|
||||
|
||||
creatingStep = 'building';
|
||||
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
|
||||
setInitialChatModel(selectedModel);
|
||||
|
||||
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
try {
|
||||
const storedModel = localStorage.getItem(AI_CHAT_MODEL_STORAGE_KEY);
|
||||
|
||||
if (isAgentModelId(storedModel)) {
|
||||
if ($isFreePlan && paidModelIds.includes(storedModel)) {
|
||||
selectedModel = DEFAULT_PICKER_MODEL_ID;
|
||||
localStorage.setItem(AI_CHAT_MODEL_STORAGE_KEY, DEFAULT_PICKER_MODEL_ID);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModel = storedModel;
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - ClassroomIO</title>
|
||||
<title>Home - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<WelcomeModal />
|
||||
{#if creatingState === 'creating'}
|
||||
<div class="flex min-h-[80vh] flex-col items-center justify-center gap-6 px-4">
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<h1 class="text-2xl font-semibold">{$t('course.creator.drafting.heading')}</h1>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
{$t('course.creator.drafting.subtext')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<CreateCourseButton variant="outline" isResponsive />
|
||||
<div class="ui:border w-full max-w-lg rounded-2xl p-5">
|
||||
<p class="mb-5 text-sm text-neutral-300 italic">"{draftingPrompt}"</p>
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<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">
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: AwardIcon,
|
||||
title: $t('dashboard.certificates_earned'),
|
||||
percentage: totalCertificates ?? 0,
|
||||
description: $t('dashboard.certificates_earned_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if !stats}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each steps as step (step.key)}
|
||||
{@const status = stepStatus(step.key)}
|
||||
<div class="flex items-center gap-3">
|
||||
{#if status === 'done'}
|
||||
<CheckCircleIcon class="h-4 w-4 shrink-0 text-green-500" />
|
||||
{:else if status === 'active'}
|
||||
<LoaderCircleIcon class="h-4 w-4 shrink-0 animate-spin text-white" />
|
||||
{:else}
|
||||
<ul class="ui:divide-border -mx-2 divide-y">
|
||||
{#each topCourses as course, i (course.id)}
|
||||
{@const rank = i + 1}
|
||||
<li>
|
||||
<a
|
||||
href={`/courses/${course.id}`}
|
||||
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
|
||||
rank === 1 &&
|
||||
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
|
||||
rank === 2 &&
|
||||
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
|
||||
rank === 3 &&
|
||||
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
|
||||
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
|
||||
]}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
|
||||
{course.title}
|
||||
</p>
|
||||
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<UsersIcon class="h-3 w-3" />
|
||||
<span class="tabular-nums">{course.enrollments}</span>
|
||||
<span>
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.completion')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
|
||||
</div>
|
||||
<Progress value={course.completion} class="ui:h-1.5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.certification_rate')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
|
||||
</div>
|
||||
<Progress value={course.certification} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<CircleIcon class="h-4 w-4 shrink-0 text-neutral-600" />
|
||||
{/if}
|
||||
<span
|
||||
class="text-sm {status === 'done'
|
||||
? 'text-neutral-400 line-through'
|
||||
: status === 'active'
|
||||
? 'ui:text-foreground'
|
||||
: 'ui:text-muted-foreground'}"
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.no_certifications_yet')}
|
||||
description={$t('dashboard.no_certifications_yet_description')}
|
||||
icon={AwardIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<LoginActivityChart data={loginActivity} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
|
||||
<div class="w-full max-w-3xl">
|
||||
<CourseCreator
|
||||
heading={$t('course.creator.heading')}
|
||||
placeholder={$t('course.creator.placeholder')}
|
||||
model={selectedModel}
|
||||
levelOptions={[
|
||||
{ value: 'beginner', label: $t('course.creator.level.beginner') },
|
||||
{ value: 'intermediate', label: $t('course.creator.level.intermediate') },
|
||||
{ value: 'advanced', label: $t('course.creator.level.advanced') }
|
||||
]}
|
||||
lockedModelIds={$isFreePlan ? paidModelIds : []}
|
||||
onModelChange={handleModelChange}
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
onsubmit={handleCreate}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { DashStatsSuccess, LoginActivityData, LoginActivitySuccess } from '$features/org/utils/types';
|
||||
import { classroomio, getApiHeaders } from '$lib/utils/services/api';
|
||||
import { type ServerApiResult, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
// TODO - Replace with actual cache
|
||||
const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
export const load = async ({ params, parent, cookies }) => {
|
||||
const { orgId } = await parent();
|
||||
const siteName = params.slug;
|
||||
|
||||
if (!orgId) {
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: null,
|
||||
loginActivity: [] as LoginActivityData
|
||||
};
|
||||
}
|
||||
|
||||
if (orgId in cache) {
|
||||
const loginActivityResult = await safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
);
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityResult.ok ? loginActivityResult.body.data : []
|
||||
};
|
||||
}
|
||||
|
||||
const [statsResult, loginActivityResult] = await Promise.allSettled([
|
||||
safeServerApi<DashStatsSuccess>(() =>
|
||||
classroomio.dash.stats.$get({ query: { siteName } }, getApiHeaders(cookies, orgId))
|
||||
),
|
||||
safeServerApi<LoginActivitySuccess>(() =>
|
||||
classroomio.dash['login-activity'].$get({ query: { orgId } }, getApiHeaders(cookies, orgId))
|
||||
)
|
||||
]);
|
||||
|
||||
cache[orgId] = statsResult.status === 'fulfilled' && statsResult.value.ok ? statsResult.value.body.data : null;
|
||||
|
||||
return {
|
||||
orgName: siteName,
|
||||
stats: cache[orgId],
|
||||
loginActivity: loginActivityDataFromSettled(loginActivityResult)
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,236 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
import BookIcon from '@lucide/svelte/icons/book';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import UsersIcon from '@lucide/svelte/icons/users';
|
||||
import { Skeleton } from '@cio/ui/base/skeleton';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { calDateDiff } from '$lib/utils/functions/date';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
|
||||
import { CreateCourseButton } from '$features/course/components';
|
||||
import { UserAvatar } from '@cio/ui/custom/user-avatar';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ActivityCard, VisitOrgSiteButton } from '$features/ui';
|
||||
import LoginActivityChart from '$features/org/components/login-activity-chart.svelte';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { getGreeting } from '$lib/utils/functions/date';
|
||||
|
||||
const { data } = $props();
|
||||
|
||||
const stats = $derived(data.stats);
|
||||
const loginActivity = $derived(data.loginActivity);
|
||||
const recentCertifications = $derived(stats?.recentCertifications || []);
|
||||
const numberOfCourses = $derived(stats?.numberOfCourses || 0);
|
||||
const totalCertificates = $derived(stats?.totalCertificates || 0);
|
||||
const totalStudents = $derived(stats?.totalStudents || 0);
|
||||
const topCourses = $derived(stats?.topCourses || []);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t(getGreeting())}
|
||||
{$profile.fullname}!
|
||||
</Page.Title>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<CreateCourseButton variant="outline" isResponsive />
|
||||
|
||||
<VisitOrgSiteButton />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<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">
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: AwardIcon,
|
||||
title: $t('dashboard.certificates_earned'),
|
||||
percentage: totalCertificates ?? 0,
|
||||
description: $t('dashboard.certificates_earned_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: BookIcon,
|
||||
title: $t('dashboard.no_of_courses'),
|
||||
percentage: numberOfCourses ?? 0,
|
||||
description: $t('dashboard.no_courses_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
<ActivityCard
|
||||
activity={{
|
||||
icon: UsersIcon,
|
||||
title: $t('dashboard.total_students'),
|
||||
percentage: totalStudents ?? 0,
|
||||
description: $t('dashboard.total_students_description'),
|
||||
hidePercentage: true
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<div class="bg-card flex min-h-[45vh] w-full flex-col rounded-xl border p-3 md:p-5 dark:text-white">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold tracking-tight">
|
||||
{$t('dashboard.top_courses')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if !stats}
|
||||
<div class="space-y-3">
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-16 w-full rounded-lg" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else if topCourses.length === 0}
|
||||
<Empty
|
||||
title={$t('dashboard.create_first_course')}
|
||||
description={$t('dashboard.create_first_course_description')}
|
||||
icon={BookIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<CreateCourseButton variant="outline" />
|
||||
</Empty>
|
||||
{:else}
|
||||
<ul class="ui:divide-border -mx-2 divide-y">
|
||||
{#each topCourses as course, i (course.id)}
|
||||
{@const rank = i + 1}
|
||||
<li>
|
||||
<a
|
||||
href={`/courses/${course.id}`}
|
||||
class="group ui:hover:bg-muted/50 flex items-center gap-3 rounded-lg px-2 py-3 transition-colors md:gap-4"
|
||||
>
|
||||
<span
|
||||
class={[
|
||||
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold tabular-nums',
|
||||
rank === 1 &&
|
||||
'ui:bg-amber-100 ui:text-amber-900 ui:dark:bg-amber-500/20 ui:dark:text-amber-200',
|
||||
rank === 2 &&
|
||||
'ui:bg-slate-100 ui:text-slate-700 ui:dark:bg-slate-500/20 ui:dark:text-slate-200',
|
||||
rank === 3 &&
|
||||
'ui:bg-orange-100 ui:text-orange-800 ui:dark:bg-orange-500/20 ui:dark:text-orange-200',
|
||||
rank > 3 && 'ui:bg-muted ui:text-muted-foreground'
|
||||
]}
|
||||
>
|
||||
{rank}
|
||||
</span>
|
||||
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="line-clamp-1 text-sm leading-snug font-medium group-hover:underline">
|
||||
{course.title}
|
||||
</p>
|
||||
<div class="ui:text-muted-foreground mt-1 flex items-center gap-1 text-xs">
|
||||
<UsersIcon class="h-3 w-3" />
|
||||
<span class="tabular-nums">{course.enrollments}</span>
|
||||
<span>
|
||||
{$t(course.enrollments === 1 ? 'dashboard.student' : 'dashboard.students')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-48 shrink-0 grid-cols-2 gap-3 sm:grid">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.completion')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.completion}%</span>
|
||||
</div>
|
||||
<Progress value={course.completion} class="ui:h-1.5" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-baseline justify-between gap-1">
|
||||
<span class="ui:text-muted-foreground text-[10px] tracking-wide uppercase">
|
||||
{$t('dashboard.certification_rate')}
|
||||
</span>
|
||||
<span class="text-xs font-semibold tabular-nums">{course.certification}%</span>
|
||||
</div>
|
||||
<Progress value={course.certification} class="ui:h-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ChevronRightIcon
|
||||
class="ui:text-muted-foreground h-4 w-4 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-[45vh] w-full flex-col rounded-md border p-3 md:p-5 dark:text-white">
|
||||
<h3 class="mb-4 text-lg">
|
||||
{$t('dashboard.recent_certifications')}
|
||||
</h3>
|
||||
|
||||
<div class="h-full space-y-6">
|
||||
{#if !stats}
|
||||
{#each Array(5) as _, i (i)}
|
||||
<Skeleton class="h-10 w-full rounded-md" />
|
||||
{/each}
|
||||
{:else}
|
||||
{#each recentCertifications as certification (`${certification.id}-${certification.courseId}-${certification.date}`)}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<UserAvatar src={certification.avatarUrl} alt={certification.name ?? 'User'} class="h-6 w-6" />
|
||||
|
||||
<div class="min-h-[45px] space-y-1">
|
||||
<p class="text-sm leading-none capitalize">{certification.name}</p>
|
||||
<p class="ui:text-muted-foreground text-sm">
|
||||
<span class="italic">
|
||||
{calDateDiff(certification.date)}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-2/4">
|
||||
<a class="hover:underline" href={`/courses/${certification.courseId}`}>
|
||||
<p class="line-clamp-2 pb-[0.1rem] text-sm leading-none">
|
||||
{certification.course}
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<Empty
|
||||
title={$t('dashboard.no_certifications_yet')}
|
||||
description={$t('dashboard.no_certifications_yet_description')}
|
||||
icon={AwardIcon}
|
||||
class="h-full"
|
||||
>
|
||||
<Button variant="outline" onclick={() => goto(`${$currentOrgPath}/courses`)}>
|
||||
{$t('dashboard.view_courses')}
|
||||
</Button>
|
||||
</Empty>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<LoginActivityChart data={loginActivity} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script>
|
||||
import { AiCreditsPage } from '$features/settings/pages';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AI Credits - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('settings.ai_credits.sub_title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('settings.ai_credits.page_subtitle')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AiCreditsPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -19,6 +19,7 @@
|
||||
import Trash2Icon from '@lucide/svelte/icons/trash-2';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programApi } from '$features/program/api';
|
||||
import type { OrgCourses } from '$features/course/types';
|
||||
import AddCourseToProgramModal from '$features/program/components/add-course-to-program-modal.svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -51,7 +52,7 @@
|
||||
description: item.course.description ?? '',
|
||||
lessonCount: 0,
|
||||
totalStudents: 0,
|
||||
isPublished: item.course.isPublished ?? false,
|
||||
isPublished: item.course.status === 'ACTIVE',
|
||||
tags: []
|
||||
}))
|
||||
);
|
||||
@@ -160,7 +161,7 @@
|
||||
<p>{item.course.description}</p>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<CoursePublishBadge isPublished={item.course.isPublished ?? false} />
|
||||
<CoursePublishBadge isPublished={item.course.status === 'ACTIVE'} />
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-center">
|
||||
<Button
|
||||
@@ -183,7 +184,7 @@
|
||||
{:else}
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
{#each courseCards as course (course.id)}
|
||||
<CourseCard {course} href={`/courses/${course.id}`}>
|
||||
<CourseCard course={course as unknown as OrgCourses[number]} href={`/courses/${course.id}`}>
|
||||
{#snippet actions()}
|
||||
<Button
|
||||
variant="outline"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import type { ProgramNewsfeedItem } from '$features/program/utils/types';
|
||||
import PinIcon from '@lucide/svelte/icons/pin';
|
||||
import { BookIcon } from '@lucide/svelte';
|
||||
import { buildNextNewsfeedReaction } from '@cio/ui/custom/newsfeed-reactions';
|
||||
import { buildNextNewsfeedReaction, type NewsfeedReactionType } from '@cio/ui/custom/newsfeed-reactions';
|
||||
|
||||
interface Props {
|
||||
data: {
|
||||
@@ -75,13 +75,13 @@
|
||||
await programNewsfeedApi.deleteComment(programId, feedId, commentId);
|
||||
};
|
||||
|
||||
const addNewReaction = async (reactionType: string, feedId: string, authorId: string) => {
|
||||
const addNewReaction = async (reactionType: NewsfeedReactionType, feedId: string, authorId: string) => {
|
||||
if (!authorId || isReactingByFeedId[feedId]) return;
|
||||
|
||||
const reactedFeed = programNewsfeedApi.feeds.find((feed) => feed.id === feedId);
|
||||
if (!reactedFeed) return;
|
||||
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction, reactionType, authorId);
|
||||
const updatedReaction = buildNextNewsfeedReaction(reactedFeed.reaction ?? undefined, reactionType, authorId);
|
||||
|
||||
isReactingByFeedId = { ...isReactingByFeedId, [feedId]: true };
|
||||
|
||||
|
||||
@@ -1,15 +1,30 @@
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags';
|
||||
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
|
||||
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
|
||||
import type { GetOrganizationRequest } from '$features/org/utils/types';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
|
||||
|
||||
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
|
||||
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
const { org: parentOrg } = await parent();
|
||||
|
||||
// PUBLIC courses are served by the anonymous org-site endpoint and bypass the
|
||||
// regular landing page entirely — redirect straight to the first item.
|
||||
const publicTreeResult = await safeServerApi<GetPublicCourseSuccess>(() =>
|
||||
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
|
||||
);
|
||||
|
||||
if (publicTreeResult.ok && publicTreeResult.body.data.items.length > 0) {
|
||||
const firstItem = publicTreeResult.body.data.items[0]!;
|
||||
throw redirect(302, `/course/${params.slug}/lesson/${firstItem.slug}`);
|
||||
}
|
||||
|
||||
const courseResult = await safeServerApi<GetCourseBySlugSuccess>(() =>
|
||||
classroomio.course.slug[':slug'].$get(
|
||||
{
|
||||
@@ -30,14 +45,16 @@ export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const course = courseResult.body.data;
|
||||
const course = courseResult.body.data as CourseBySlugWithOrg;
|
||||
|
||||
let org = parentOrg ?? null;
|
||||
if (!org && course.org?.siteName) {
|
||||
const courseOrgSiteName = course.org?.siteName;
|
||||
|
||||
if (!org && courseOrgSiteName) {
|
||||
const organizationResult = await safeServerApi<GetOrganizationSuccess>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: { siteName: course.org.siteName }
|
||||
query: { siteName: courseOrgSiteName }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { CourseLandingPage } from '$features/ui';
|
||||
import { PoweredBy } from '$features/ui';
|
||||
import { normalizeLandingPageSettings, importThemeComponent } from '$features/org/utils/landing-page';
|
||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
|
||||
import { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page';
|
||||
import type { Component } from 'svelte';
|
||||
@@ -39,13 +39,16 @@
|
||||
return landingSettings.hero;
|
||||
}
|
||||
|
||||
const enrollmentsOpen = data.course.metadata?.allowNewStudent === true;
|
||||
|
||||
return {
|
||||
...landingSettings.hero,
|
||||
heading: data.course.title,
|
||||
subheading: data.course.description || landingSettings.hero.subheading,
|
||||
primaryAction: {
|
||||
label: t.get('course.navItem.landing_page.start_course'),
|
||||
href: resolve(`/course/${data.course.slug}/enroll`, {})
|
||||
href: resolve(`/course/${data.course.slug}/enroll`, {}),
|
||||
disabled: !enrollmentsOpen
|
||||
},
|
||||
image: data.course.logo || landingSettings.hero.image
|
||||
};
|
||||
@@ -97,7 +100,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<CourseLandingPage courseData={data.course} showStandaloneShell={false} />
|
||||
<CourseLandingPage courseData={data.course} />
|
||||
|
||||
<OrgLandingPageFooter
|
||||
{orgName}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
|
||||
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { calcCourseDiscount } from '$lib/utils/functions/course';
|
||||
@@ -66,7 +66,7 @@ export const load = async ({ params, url }) => {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const courseData = courseResult.body.data;
|
||||
const courseData = courseResult.body.data as CourseBySlugWithOrg;
|
||||
const discount = (courseData.metadata as { discount?: number } | null)?.discount ?? 0;
|
||||
const showDiscount = (courseData.metadata as { showDiscount?: boolean } | null)?.showDiscount ?? false;
|
||||
const calculatedCost = calcCourseDiscount(discount, Number(courseData.cost ?? 0), showDiscount);
|
||||
|
||||
@@ -103,13 +103,17 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!data.currentOrg) return;
|
||||
setTheme(data.currentOrg?.theme);
|
||||
|
||||
const rawTheme = data.currentOrg.theme;
|
||||
const themeString = typeof rawTheme === 'string' ? rawTheme : '';
|
||||
|
||||
setTheme(themeString);
|
||||
currentOrg.update((o) => ({
|
||||
...o,
|
||||
id: data.currentOrg?.id ?? o.id,
|
||||
name: data.currentOrg?.name ?? o.name,
|
||||
siteName: data.currentOrg?.siteName ?? o.siteName,
|
||||
theme: data.currentOrg?.theme ?? o.theme
|
||||
theme: themeString || o.theme
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags';
|
||||
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
|
||||
type GetPublicCourseItemRequest =
|
||||
(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get'];
|
||||
|
||||
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
|
||||
type GetPublicCourseItemSuccess = Extract<InferResponseType<GetPublicCourseItemRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params = { slug: '', itemSlug: '' } }) => {
|
||||
const [treeResult, itemResult] = await Promise.all([
|
||||
safeServerApi<GetPublicCourseSuccess>(() =>
|
||||
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
|
||||
),
|
||||
safeServerApi<GetPublicCourseItemSuccess>(() =>
|
||||
classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].$get(
|
||||
{ param: { courseSlug: params.slug, itemSlug: params.itemSlug } },
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
)
|
||||
]);
|
||||
|
||||
if (!treeResult.ok || !itemResult.ok) {
|
||||
throw error(404, 'Public course or lesson not found');
|
||||
}
|
||||
|
||||
const tree = treeResult.body.data;
|
||||
const item = itemResult.body.data;
|
||||
|
||||
const itemTitle = 'title' in item ? item.title : tree.course.title;
|
||||
const itemSummary =
|
||||
item.kind === 'lesson' && typeof item.body === 'string'
|
||||
? item.body.replace(/<[^>]+>/g, '').slice(0, 155)
|
||||
: (tree.course.description ?? '');
|
||||
|
||||
const pageMetaTags = Object.freeze({
|
||||
title: `${itemTitle} · ${tree.course.title}`,
|
||||
description: itemSummary || tree.course.description || '',
|
||||
openGraph: {
|
||||
title: itemTitle,
|
||||
description: itemSummary || tree.course.description || '',
|
||||
images: tree.course.bannerImage
|
||||
? [
|
||||
{
|
||||
url: tree.course.bannerImage,
|
||||
alt: tree.course.title,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
secureUrl: tree.course.bannerImage,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}
|
||||
}) satisfies MetaTagsProps;
|
||||
|
||||
return {
|
||||
tree,
|
||||
item,
|
||||
pageMetaTags
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { PublicCourse } from '@cio/ui';
|
||||
import {
|
||||
toPublicExerciseView,
|
||||
toPublicLessonView,
|
||||
toPublicSidebarSections
|
||||
} from '$features/course/utils/public-course-mappers';
|
||||
import {
|
||||
publicExerciseAttemptsStorageKey,
|
||||
type PublicCourseSidebarItem,
|
||||
type PublicCourseSidebarSection,
|
||||
type PublicLessonViewData,
|
||||
type PublicExerciseViewData
|
||||
} from '@cio/ui/custom/public-course';
|
||||
import { getExerciseQuestionLabels } from '$features/course/components/exercise/question-labels';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
|
||||
const exerciseLabels = $derived(getExerciseQuestionLabels());
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const sections: PublicCourseSidebarSection[] = $derived(toPublicSidebarSections(data.tree));
|
||||
const flatItems = $derived(sections.flatMap((section) => section.items));
|
||||
|
||||
const itemSlug = $derived(data.item.slug);
|
||||
const activeIndex = $derived(flatItems.findIndex((entry) => entry.slug === itemSlug));
|
||||
const activeItem = $derived<PublicCourseSidebarItem | null>(flatItems[activeIndex] ?? null);
|
||||
const prevItem = $derived(activeIndex > 0 ? flatItems[activeIndex - 1] : null);
|
||||
const nextItem = $derived(activeIndex >= 0 && activeIndex < flatItems.length - 1 ? flatItems[activeIndex + 1] : null);
|
||||
|
||||
const callout = $derived(data.tree.course.callout ?? null);
|
||||
const courseTitle = $derived(data.tree.course.title);
|
||||
const org = $derived(data.tree.course.org ?? null);
|
||||
|
||||
const hrefFor = (item: PublicCourseSidebarItem) => `/course/${data.tree.course.slug}/lesson/${item.slug}`;
|
||||
|
||||
async function navigateTo(item: PublicCourseSidebarItem | null) {
|
||||
if (!item) return;
|
||||
|
||||
await goto(hrefFor(item));
|
||||
}
|
||||
|
||||
const lessonView = $derived<PublicLessonViewData | null>(
|
||||
data.item.kind === 'lesson' ? toPublicLessonView(data.item) : null
|
||||
);
|
||||
const exerciseView = $derived<PublicExerciseViewData | null>(
|
||||
data.item.kind === 'exercise' ? toPublicExerciseView(data.item) : null
|
||||
);
|
||||
</script>
|
||||
|
||||
<PublicCourse.PublicCourseShell
|
||||
{sections}
|
||||
{courseTitle}
|
||||
{org}
|
||||
activeSlug={itemSlug}
|
||||
{activeItem}
|
||||
activeFlatIndex={activeIndex >= 0 ? activeIndex : null}
|
||||
totalItems={flatItems.length}
|
||||
hasPrev={!!prevItem}
|
||||
hasNext={!!nextItem}
|
||||
{prevItem}
|
||||
{nextItem}
|
||||
{hrefFor}
|
||||
exploreHref="/courses"
|
||||
signInHref="/login"
|
||||
exploreLabel={$t('public_course.header.explore_courses')}
|
||||
signInLabel={$t('public_course.header.sign_in')}
|
||||
footerPrevLabel={$t('public_course.footer_nav.previous')}
|
||||
footerNextLabel={$t('public_course.footer_nav.next')}
|
||||
courseSlug={data.tree.course.slug}
|
||||
poweredByLabel={$t('public_course.powered_by.label')}
|
||||
poweredByBrand={$t('public_course.powered_by.brand')}
|
||||
onItemClick={navigateTo}
|
||||
onPrev={() => navigateTo(prevItem)}
|
||||
onNext={() => navigateTo(nextItem)}
|
||||
>
|
||||
{#if lessonView}
|
||||
<PublicCourse.PublicLessonView lesson={lessonView} {callout} />
|
||||
{:else if exerciseView}
|
||||
{#key itemSlug}
|
||||
<PublicCourse.PublicExerciseView
|
||||
exercise={exerciseView}
|
||||
{callout}
|
||||
labels={exerciseLabels}
|
||||
attemptsPersistenceKey={publicExerciseAttemptsStorageKey(data.tree.course.slug, itemSlug)}
|
||||
formatAttemptOption={({ attemptNumber, correct, total }) =>
|
||||
t.get('public_course.exercise.attempt_option', { attemptNumber, correct, total })}
|
||||
newAttemptOptionLabel={$t('public_course.exercise.practice_again')}
|
||||
attemptsSelectAriaLabel={$t('public_course.exercise.attempts_select_aria')}
|
||||
submitLabel={$t('public_course.exercise.submit')}
|
||||
tryAgainLabel={$t('public_course.exercise.try_again')}
|
||||
privacyHint={$t('public_course.exercise.privacy_hint')}
|
||||
summaryTemplate={$t('public_course.exercise.summary_template')}
|
||||
/>
|
||||
{/key}
|
||||
{/if}
|
||||
</PublicCourse.PublicCourseShell>
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Polar } from '@polar-sh/sdk';
|
||||
import { TOKEN_PACK } from '@cio/utils/plans';
|
||||
import { dev } from '$app/environment';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
export const GET = async ({ url }: { url: URL }) => {
|
||||
const quantityRaw = Number(url.searchParams.get('quantity') ?? '1');
|
||||
const quantity = Math.min(100, Math.max(1, Number.isFinite(quantityRaw) ? Math.trunc(quantityRaw) : 1));
|
||||
|
||||
let metadata: { orgId?: string; orgSlug?: string; triggeredBy?: string };
|
||||
|
||||
try {
|
||||
metadata = JSON.parse(url.searchParams.get('metadata') ?? '{}');
|
||||
} catch {
|
||||
return new Response('Invalid metadata JSON', { status: 400 });
|
||||
}
|
||||
|
||||
if (!metadata.orgId || !metadata.orgSlug) {
|
||||
return new Response('Missing orgId / orgSlug in metadata', { status: 400 });
|
||||
}
|
||||
|
||||
const productId = env.POLAR_TOKEN_PACK_PRODUCT_ID;
|
||||
|
||||
if (!productId) {
|
||||
return new Response('POLAR_TOKEN_PACK_PRODUCT_ID is not configured', { status: 500 });
|
||||
}
|
||||
|
||||
if (!env.POLAR_ACCESS_TOKEN) {
|
||||
return new Response('POLAR_ACCESS_TOKEN is not configured', { status: 500 });
|
||||
}
|
||||
|
||||
const polar = new Polar({
|
||||
accessToken: env.POLAR_ACCESS_TOKEN,
|
||||
server: dev ? 'sandbox' : 'production'
|
||||
});
|
||||
|
||||
const totalPriceCents = TOKEN_PACK.PRICE_USD_CENTS * quantity;
|
||||
|
||||
const checkout = await polar.checkouts.create({
|
||||
products: [productId],
|
||||
prices: {
|
||||
[productId]: [
|
||||
{
|
||||
amountType: 'fixed',
|
||||
priceAmount: totalPriceCents,
|
||||
priceCurrency: 'usd'
|
||||
}
|
||||
]
|
||||
},
|
||||
customerEmail: url.searchParams.get('customerEmail') ?? undefined,
|
||||
customerName: url.searchParams.get('customerName') ?? undefined,
|
||||
successUrl: `${url.origin}/org/${metadata.orgSlug}/settings/ai-credits?tokens=success`,
|
||||
metadata: {
|
||||
kind: 'token_pack',
|
||||
orgId: metadata.orgId,
|
||||
orgSlug: metadata.orgSlug,
|
||||
triggeredBy: metadata.triggeredBy ?? '',
|
||||
tokensPerUnit: String(TOKEN_PACK.TOKENS_PER_UNIT),
|
||||
quantity: String(quantity)
|
||||
}
|
||||
});
|
||||
|
||||
return Response.redirect(checkout.url, 303);
|
||||
};
|
||||
@@ -1,6 +1,12 @@
|
||||
import { OrgPlanApiServer } from '$features/org/api/org-plan.server';
|
||||
import { PLAN } from '@cio/utils/plans';
|
||||
import type { PolarWebhookPayload } from '$lib/utils/types/polar';
|
||||
import { CreditPurchaseApiServer } from '$features/agent/api/credit-purchase.server';
|
||||
import { PLAN, TOKEN_PACK } from '@cio/utils/plans';
|
||||
import type {
|
||||
PolarOrderWebhookPayload,
|
||||
PolarSubscriptionWebhookPayload,
|
||||
PolarWebhookPayload,
|
||||
SubscriptionData
|
||||
} from '$lib/utils/types/polar';
|
||||
import { Webhooks } from '@polar-sh/sveltekit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
|
||||
@@ -9,24 +15,86 @@ export const POST = Webhooks({
|
||||
onPayload
|
||||
});
|
||||
|
||||
function isSubscriptionPayload(payload: PolarWebhookPayload): payload is PolarSubscriptionWebhookPayload {
|
||||
return (
|
||||
payload.type === 'checkout.created' ||
|
||||
payload.type === 'checkout.updated' ||
|
||||
payload.type.startsWith('subscription.')
|
||||
);
|
||||
}
|
||||
|
||||
function isOrderPayload(payload: PolarWebhookPayload): payload is PolarOrderWebhookPayload {
|
||||
return payload.type === 'order.paid' || payload.type === 'order.created' || payload.type === 'order.refunded';
|
||||
}
|
||||
|
||||
async function onPayload(payload: PolarWebhookPayload) {
|
||||
const metadata = payload.data.metadata;
|
||||
console.log('Polar webhook', payload.type);
|
||||
|
||||
if (isOrderPayload(payload)) {
|
||||
if (payload.type !== 'order.paid') {
|
||||
return;
|
||||
}
|
||||
|
||||
const order = payload.data;
|
||||
const md = order.metadata ?? {};
|
||||
|
||||
if (md.kind !== 'token_pack' || !md.orgId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quantityFromItems = order.items?.[0]?.quantity;
|
||||
const quantityParsed = Number(md.quantity ?? '1');
|
||||
const quantity =
|
||||
quantityFromItems != null && quantityFromItems > 0
|
||||
? quantityFromItems
|
||||
: Number.isFinite(quantityParsed) && quantityParsed > 0
|
||||
? Math.trunc(quantityParsed)
|
||||
: 1;
|
||||
const tokensPerUnit = Number(md.tokensPerUnit ?? '') || TOKEN_PACK.TOKENS_PER_UNIT;
|
||||
const tokens = quantity * tokensPerUnit;
|
||||
const totalCents = order.totalAmount ?? order.amount ?? 0;
|
||||
const unitPriceCents = quantity > 0 ? Math.round(totalCents / quantity) : totalCents;
|
||||
|
||||
const triggeredByRaw = md.triggeredBy?.trim();
|
||||
let triggeredBy: string | undefined;
|
||||
|
||||
if (
|
||||
triggeredByRaw &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(triggeredByRaw)
|
||||
) {
|
||||
triggeredBy = triggeredByRaw;
|
||||
}
|
||||
|
||||
try {
|
||||
await CreditPurchaseApiServer.recordPurchase({
|
||||
orgId: md.orgId,
|
||||
triggeredBy,
|
||||
providerOrderId: order.id,
|
||||
tokens,
|
||||
quantity,
|
||||
unitPriceCents,
|
||||
currency: order.currency ?? 'USD',
|
||||
payload: order as unknown as Record<string, unknown>
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error recording credit purchase', error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSubscriptionPayload(payload)) {
|
||||
console.log('Unknown Polar event', payload.type);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const data = payload.data as SubscriptionData;
|
||||
const metadata = data.metadata;
|
||||
const orgId = metadata.orgId;
|
||||
const triggeredBy = metadata.triggeredBy;
|
||||
const subscriptionId = payload.data.id;
|
||||
const isSubscriptionActive = payload.data.status === 'active';
|
||||
|
||||
console.log({
|
||||
orgId,
|
||||
triggeredBy,
|
||||
isSubscriptionActive,
|
||||
subscriptionId
|
||||
});
|
||||
|
||||
if (!orgId) {
|
||||
console.error('Org ID is required');
|
||||
throw new Error('Org ID is required');
|
||||
}
|
||||
const subscriptionId = data.id;
|
||||
const isSubscriptionActive = data.status === 'active';
|
||||
|
||||
switch (payload.type) {
|
||||
case 'checkout.created':
|
||||
@@ -34,14 +102,24 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
case 'checkout.updated':
|
||||
break;
|
||||
case 'subscription.created':
|
||||
if (metadata.kind === 'token_pack') {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!orgId || triggeredBy === undefined || triggeredBy === '') {
|
||||
console.error('subscription.created missing org metadata');
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
if (isSubscriptionActive) {
|
||||
try {
|
||||
const planData = {
|
||||
orgId,
|
||||
triggeredBy: parseInt(triggeredBy),
|
||||
triggeredBy: parseInt(triggeredBy, 10),
|
||||
planName: PLAN.EARLY_ADOPTER as 'EARLY_ADOPTER' | 'ENTERPRISE' | 'BASIC',
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
payload: data as unknown as Record<string, unknown>
|
||||
};
|
||||
|
||||
const result = await OrgPlanApiServer.createOrgPlan(planData);
|
||||
@@ -50,31 +128,31 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
console.error('Error creating org plan', error);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case 'subscription.updated':
|
||||
if (!isSubscriptionActive) {
|
||||
// Handle Cancel Subscription
|
||||
try {
|
||||
const result = await OrgPlanApiServer.cancelOrgPlan({
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
payload: data as unknown as Record<string, unknown>
|
||||
});
|
||||
console.log('Subscription canceled', result);
|
||||
} catch (error) {
|
||||
console.error('Error canceling org plan', error);
|
||||
}
|
||||
} else {
|
||||
// Handle Update Subscription
|
||||
try {
|
||||
const result = await OrgPlanApiServer.updateOrgPlan({
|
||||
subscriptionId,
|
||||
payload: payload.data as unknown as Record<string, unknown>
|
||||
payload: data as unknown as Record<string, unknown>
|
||||
});
|
||||
console.log('Subscription updated', result);
|
||||
} catch (error) {
|
||||
console.error('Error updating org plan', error);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
case 'subscription.active':
|
||||
break;
|
||||
@@ -83,7 +161,9 @@ async function onPayload(payload: PolarWebhookPayload) {
|
||||
case 'subscription.canceled':
|
||||
break;
|
||||
default:
|
||||
console.log('Unknown event', payload.type);
|
||||
// Exhaustive over `PolarSubscriptionWebhookPayload`; kept for future Polar event types.
|
||||
console.log('Unknown subscription-related event');
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"type": "order.paid",
|
||||
"data": {
|
||||
"id": "ord_fixture_001",
|
||||
"amount": 500,
|
||||
"totalAmount": 1500,
|
||||
"currency": "usd",
|
||||
"metadata": {
|
||||
"kind": "token_pack",
|
||||
"orgId": "00000000-0000-4000-8000-000000000001",
|
||||
"orgSlug": "demo-org",
|
||||
"triggeredBy": "00000000-0000-4000-8000-000000000002",
|
||||
"tokensPerUnit": "2000000",
|
||||
"quantity": "3"
|
||||
},
|
||||
"items": [{ "quantity": 3, "amount": 1500 }]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user