feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -0,0 +1,82 @@
import type {
AccountUsage,
AccountWorkspaceLimits,
AccountWorkspaceList,
CreateAccountWorkspaceRequest,
DeleteAccountWorkspaceRequest,
GetAccountUsageRequest,
ListAccountWorkspacesRequest
} from '../utils/types';
import { BaseApiWithErrors, classroomio } from '$lib/utils/services/api';
import { ZCreateWorkspace, type TCreateWorkspace } from '@cio/utils/validation/account';
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
class AccountApi extends BaseApiWithErrors {
workspaces = $state<AccountWorkspaceList>([]);
limits = $state<AccountWorkspaceLimits | null>(null);
usage = $state<AccountUsage | null>(null);
async listWorkspaces() {
return this.execute<ListAccountWorkspacesRequest>({
requestFn: () => classroomio.account.workspaces.$get(),
logContext: 'fetching account workspaces',
onSuccess: (response) => {
this.workspaces = response.data.workspaces;
this.limits = response.data.limits;
}
});
}
async loadUsage() {
return this.execute<GetAccountUsageRequest>({
requestFn: () => classroomio.account.usage.$get(),
logContext: 'fetching account usage',
onSuccess: (response) => {
this.usage = response.data;
}
});
}
async createWorkspace(fields: TCreateWorkspace) {
const parsed = ZCreateWorkspace.safeParse(fields);
if (!parsed.success) {
this.errors = mapZodErrorsToTranslations(parsed.error);
return;
}
return this.execute<CreateAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces.$post({ json: parsed.data }),
logContext: 'creating workspace',
onSuccess: async (response) => {
this.workspaces = [...this.workspaces, response.data];
snackbar.success('snackbar.account.workspaceCreated');
await this.listWorkspaces();
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
async deleteWorkspace(workspaceId: string) {
return this.execute<DeleteAccountWorkspaceRequest>({
requestFn: () => classroomio.account.workspaces[':workspaceId'].$delete({ param: { workspaceId } }),
logContext: 'deleting workspace',
onSuccess: () => {
this.workspaces = this.workspaces.filter((row) => row.id !== workspaceId);
snackbar.success('snackbar.account.workspaceDeleted');
},
onError: (result) => {
if (typeof result === 'object' && 'code' in result) {
snackbar.error(`snackbar.account.${String(result.code).toLowerCase()}`);
}
}
});
}
}
export const accountApi = new AccountApi();
@@ -0,0 +1,2 @@
export { default as WorkspacesPage } from './pages/workspaces.svelte';
export { accountApi } from './api/account.svelte';
@@ -0,0 +1,190 @@
<script lang="ts">
import { Badge } from '@cio/ui/base/badge';
import { Button } from '@cio/ui/base/button';
import { Input } from '@cio/ui/base/input';
import { Spinner } from '@cio/ui/base/spinner';
import * as Dialog from '@cio/ui/base/dialog';
import * as Field from '@cio/ui/base/field';
import { accountApi } from '$features/account/api/account.svelte';
import { currentOrg, isPrimaryWorkspace } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
let isCreateOpen = $state(false);
let formName = $state('');
let formSiteName = $state('');
let confirmDeleteId = $state<string | null>(null);
$effect(() => {
if (!$currentOrg.id) return;
accountApi.listWorkspaces();
accountApi.loadUsage();
});
const limits = $derived(accountApi.limits);
const usage = $derived(accountApi.usage);
const workspaces = $derived(accountApi.workspaces);
const ctaState = $derived.by(() => {
if (!limits) return { kind: 'loading' as const };
if (limits.requiresUpgrade) {
return { kind: 'upgrade' as const };
}
if (!limits.canCreate) {
return { kind: 'limit' as const, allowance: limits.allowance };
}
return { kind: 'create' as const, used: limits.used, allowance: limits.allowance };
});
async function onCreate() {
await accountApi.createWorkspace({ name: formName, siteName: formSiteName });
if (accountApi.success) {
formName = '';
formSiteName = '';
isCreateOpen = false;
}
}
async function onDelete(id: string) {
await accountApi.deleteWorkspace(id);
confirmDeleteId = null;
}
</script>
{#if !$isPrimaryWorkspace}
<div class="ui:text-muted-foreground rounded border p-4">
{$t('account.workspaces.secondary_billing_notice', { primaryName: $currentOrg.name })}
</div>
{:else}
<Field.Group class="w-full max-w-2xl px-2">
{#if usage}
<Field.Set>
<div class="rounded border p-4">
<p class="text-sm">
{$t('account.workspaces.usage_banner', {
learners: usage.learnerCount,
tokens: usage.tokensUsedThisMonth,
workspaces: usage.workspaceCount
})}
</p>
</div>
</Field.Set>
<Field.Separator />
{/if}
<Field.Set>
<div class="flex items-center justify-between">
<Field.Legend>{$t('account.workspaces.page_title')}</Field.Legend>
{#if ctaState.kind === 'create'}
<Button onclick={() => (isCreateOpen = true)}>
{$t('account.workspaces.create_cta')}
<span class="ui:text-muted-foreground ml-2">
{$t('account.workspaces.create_cta_used', { used: ctaState.used, allowance: ctaState.allowance })}
</span>
</Button>
{:else if ctaState.kind === 'limit'}
<Button disabled variant="outline">
{$t('account.workspaces.create_cta_disabled_limit', { allowance: ctaState.allowance })}
</Button>
{:else if ctaState.kind === 'upgrade'}
<Button variant="outline" href="/pricing">
{$t('account.workspaces.create_cta_disabled_upgrade')}
<span class="ml-2 underline">{$t('account.workspaces.upgrade_link')}</span>
</Button>
{/if}
</div>
<Field.Group>
{#if accountApi.isLoading && workspaces.length === 0}
<Spinner class="size-10! text-blue-700!" />
{:else}
{#each workspaces as workspace}
<Field.Field>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<p class="text-sm font-medium">{workspace.name}</p>
<span class="ui:text-muted-foreground text-xs">{workspace.siteName}</span>
{#if workspace.isPrimary}
<Badge variant="secondary">{$t('account.workspaces.primary_badge')}</Badge>
{/if}
{#if workspace.readOnlyUntil}
<Badge variant="outline" class="text-yellow-700">{$t('account.workspaces.read_only_badge')}</Badge>
{/if}
</div>
{#if !workspace.isPrimary}
<Button
variant="ghost"
class="text-red-500 hover:text-red-700"
onclick={() => (confirmDeleteId = workspace.id)}
>
{$t('account.workspaces.delete_action')}
</Button>
{/if}
</div>
</Field.Field>
{/each}
{/if}
</Field.Group>
</Field.Set>
</Field.Group>
{/if}
<Dialog.Root bind:open={isCreateOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.create_modal_title')}</Dialog.Title>
</Dialog.Header>
<Field.Group>
<Field.Field>
<Field.Label>{$t('account.workspaces.name_label')}</Field.Label>
<Input bind:value={formName} />
{#if accountApi.errors.name}
<Field.Error>{accountApi.errors.name}</Field.Error>
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('account.workspaces.site_name_label')}</Field.Label>
<Input bind:value={formSiteName} />
{#if accountApi.errors.siteName}
<Field.Error>{accountApi.errors.siteName}</Field.Error>
{/if}
</Field.Field>
</Field.Group>
<Dialog.Footer>
<Button variant="outline" onclick={() => (isCreateOpen = false)}>{$t('account.workspaces.cancel')}</Button>
<Button onclick={onCreate} loading={accountApi.isLoading} disabled={accountApi.isLoading}>
{$t('account.workspaces.create_modal_submit')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<Dialog.Root open={confirmDeleteId !== null} onOpenChange={(open) => !open && (confirmDeleteId = null)}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('account.workspaces.delete_confirm_title')}</Dialog.Title>
</Dialog.Header>
<p class="ui:text-muted-foreground">{$t('account.workspaces.delete_confirm_body')}</p>
<Dialog.Footer>
<Button variant="outline" onclick={() => (confirmDeleteId = null)}>{$t('account.workspaces.cancel')}</Button>
<Button
variant="destructive"
onclick={() => confirmDeleteId && onDelete(confirmDeleteId)}
loading={accountApi.isLoading}
disabled={accountApi.isLoading}
>
{$t('account.workspaces.delete_action')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,14 @@
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
export type ListAccountWorkspacesRequest = typeof classroomio.account.workspaces.$get;
export type CreateAccountWorkspaceRequest = typeof classroomio.account.workspaces.$post;
export type DeleteAccountWorkspaceRequest = (typeof classroomio.account.workspaces)[':workspaceId']['$delete'];
export type GetAccountUsageRequest = typeof classroomio.account.usage.$get;
type ListSuccess = Extract<InferResponseType<ListAccountWorkspacesRequest>, { success: true }>;
type UsageSuccess = Extract<InferResponseType<GetAccountUsageRequest>, { success: true }>;
export type AccountWorkspaceList = ListSuccess['data']['workspaces'];
export type AccountWorkspaceLimits = ListSuccess['data']['limits'];
export type AccountWorkspace = AccountWorkspaceList[number];
export type AccountUsage = UsageSuccess['data'];