feat: certificates, landing pages, ai tutor, website update, compliance
This commit is contained in:
@@ -13,8 +13,10 @@
|
||||
import { courseApi } from '$features/course/api';
|
||||
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 { aiAssistantPanelDefinition } from '$features/ai-assistant';
|
||||
import { initialChatPrompt, openAiAssistant } from '$features/ai-assistant/utils/store';
|
||||
import { sidePanel, SidePanelRail } from '$features/side-panel';
|
||||
import { transcriptPanelDefinition } from '$features/course/components/lesson/video/transcript-panel-definition';
|
||||
import { get } from 'svelte/store';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
@@ -27,6 +29,9 @@
|
||||
COURSE_SIDEBAR_STORAGE_KEY
|
||||
} from '$features/course/components/sidebar/constants';
|
||||
|
||||
sidePanel.register(aiAssistantPanelDefinition);
|
||||
sidePanel.register(transcriptPanelDefinition);
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
path: string;
|
||||
@@ -186,5 +191,5 @@
|
||||
{/if}
|
||||
</Sidebar.Inset>
|
||||
|
||||
<AiCourseChat courseId={data.courseId} />
|
||||
<SidePanelRail />
|
||||
</Sidebar.Provider>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { CourseAiTutorSettingsPage } from '$features/ai-tutor-settings';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<CourseAiTutorSettingsPage courseId={data.courseId} />
|
||||
@@ -7,12 +7,13 @@
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="mx-auto flex md:max-w-3xl lg:max-w-4xl">
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>
|
||||
{$t('analytics.title')}
|
||||
</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('analytics.course_metrics.page_subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RefreshPageData />
|
||||
@@ -20,7 +21,7 @@
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<AnalyticsPage courseId={data.courseId} courseAnalytics={data.courseAnalytics} />
|
||||
<AnalyticsPage courseAnalytics={data.courseAnalytics} />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
|
||||
const certificateTheme = $derived(courseApi.course?.certificate?.theme ?? '');
|
||||
|
||||
const currentCertificateState = $derived.by(() => getCertificateStateSnapshot(courseApi.course));
|
||||
|
||||
const userRole = $derived.by(() => {
|
||||
const user = courseApi.group.people.find((person) => person.profileId === $profile.id);
|
||||
return user ? Number(user.roleId) : null;
|
||||
@@ -40,7 +38,8 @@
|
||||
description: course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: course.certificate?.isDownloadable ?? false,
|
||||
theme: parseCertificateThemeId(course.certificate?.theme) ?? 'professional',
|
||||
theme: parseCertificateThemeId(course.certificate?.theme),
|
||||
design: course.certificate?.design ?? null,
|
||||
deadline: course.certificate?.deadline ?? null,
|
||||
threshold: typeof course.certificate?.threshold === 'number' ? course.certificate.threshold : 100,
|
||||
requiredExerciseId: course.certificate?.requiredExerciseId ?? null,
|
||||
@@ -92,7 +91,8 @@
|
||||
description: courseApi.course.description ?? '',
|
||||
certificate: {
|
||||
isDownloadable: courseApi.course.certificate?.isDownloadable ?? false,
|
||||
theme: certificateTheme || 'professional',
|
||||
theme: certificateTheme || 'classique',
|
||||
design: courseApi.course.certificate?.design,
|
||||
deadline: courseApi.course.certificate?.deadline ?? null,
|
||||
threshold:
|
||||
typeof courseApi.course.certificate?.threshold === 'number' ? courseApi.course.certificate.threshold : 100,
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
let { children }: { children?: import('svelte').Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children?.()}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import CertificateEditor from '$features/course/components/ceritficate/editor/certificate-editor.svelte';
|
||||
|
||||
const courseId = $derived(page.params.id ?? '');
|
||||
|
||||
$effect(() => {
|
||||
if (!courseId || !$profile.id) return;
|
||||
courseApi.ensureCourse(courseId, $profile.id);
|
||||
});
|
||||
|
||||
const isReady = $derived(courseApi.course?.id === courseId);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{courseApi.course?.title ?? $t('course.navItem.certificates.editor.title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="ui:bg-background min-h-screen w-full">
|
||||
{#if isReady}
|
||||
<CertificateEditor {courseId} />
|
||||
{:else}
|
||||
<div class="flex min-h-[60vh] items-center justify-center">
|
||||
<Spinner class="size-8!" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 360px; --sidebar-width-icon: 4rem">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<Editor {courseId} bind:course={courseApi.course} {syncCourseStore} onClose={handleClose} />
|
||||
</Sidebar.Root>
|
||||
|
||||
@@ -35,7 +35,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-[90%] px-4 md:max-w-4xl lg:max-w-5xl">
|
||||
{#key data.lessonId}
|
||||
<LessonPage courseId={data.courseId} lessonId={data.lessonId} />
|
||||
{/key}
|
||||
|
||||
@@ -10,7 +10,16 @@
|
||||
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 {
|
||||
setInitialChatModel,
|
||||
setInitialChatPrompt,
|
||||
setInitialChatTemplateId
|
||||
} from '$features/ai-assistant/utils/store';
|
||||
import { DISPLAY_BY_ID } from '$features/ai-assistant/utils/template-display';
|
||||
import { COURSE_TEMPLATES, type CourseTemplate, type CourseTemplateId } from '@cio/ai-assistant';
|
||||
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
|
||||
import CompassIcon from '@lucide/svelte/icons/compass';
|
||||
import AwardIcon from '@lucide/svelte/icons/award';
|
||||
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';
|
||||
@@ -33,6 +42,9 @@
|
||||
let creatingState: 'idle' | 'creating' = $state('idle');
|
||||
let creatingStep: CreatingStep = $state('reading');
|
||||
let draftingPrompt = $state('');
|
||||
let composerPrompt = $state('');
|
||||
let pinnedTemplatePrompt: string | null = $state(null);
|
||||
let selectedTemplateId: CourseTemplateId | null = $state(null);
|
||||
let selectedModel: AgentModelId = $state(DEFAULT_PICKER_MODEL_ID);
|
||||
const paidModelIds = UI_PICKER_MODEL_IDS.filter((id) => !AGENT_MODELS[id].isFree);
|
||||
|
||||
@@ -86,6 +98,23 @@
|
||||
}
|
||||
});
|
||||
|
||||
function selectTemplate(template: CourseTemplate) {
|
||||
composerPrompt = template.promptTemplate;
|
||||
pinnedTemplatePrompt = template.promptTemplate;
|
||||
selectedTemplateId = template.id;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!selectedTemplateId || pinnedTemplatePrompt === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (composerPrompt !== pinnedTemplatePrompt) {
|
||||
selectedTemplateId = null;
|
||||
pinnedTemplatePrompt = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function handleCreate({ prompt, level, model }: { prompt: string; level: string; model?: string }) {
|
||||
if (isAgentModelId(model)) {
|
||||
if ($isFreePlan && paidModelIds.includes(model)) {
|
||||
@@ -103,16 +132,33 @@
|
||||
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);
|
||||
|
||||
const template = selectedTemplateId ? COURSE_TEMPLATES.find((tpl) => tpl.id === selectedTemplateId) : undefined;
|
||||
|
||||
const promptIsStillTemplateDefault = template !== undefined && prompt.trim() === template.promptTemplate.trim();
|
||||
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
if (promptIsStillTemplateDefault) {
|
||||
title = t.get('course.creator.untitled_course');
|
||||
description = t.get('course.creator.untitled_course_description');
|
||||
} else {
|
||||
const meta = await aiAssistantApi.generateCourseMeta(prompt);
|
||||
title = meta?.title ?? prompt.slice(0, 80);
|
||||
description = meta?.description ?? prompt.slice(0, 150);
|
||||
}
|
||||
|
||||
creatingStep = 'building';
|
||||
setInitialChatPrompt(`${prompt}\n\nCourse type: Self-paced. Level: ${level}.`);
|
||||
setInitialChatModel(selectedModel);
|
||||
|
||||
if (selectedTemplateId) {
|
||||
setInitialChatTemplateId(selectedTemplateId);
|
||||
}
|
||||
|
||||
await courseApi.create({ title, description, type: 'SELF_PACED' as TCourseType }, (courseId) => {
|
||||
goto(resolve(`/courses/${courseId}`, {}));
|
||||
goto(resolve(`/courses/${courseId}/lessons`, {}));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -177,11 +223,12 @@
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-[80vh] items-center justify-center px-4 py-12">
|
||||
<div class="flex min-h-[90vh] 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')}
|
||||
bind:prompt={composerPrompt}
|
||||
model={selectedModel}
|
||||
levelOptions={[
|
||||
{ value: 'beginner', label: $t('course.creator.level.beginner') },
|
||||
@@ -193,6 +240,41 @@
|
||||
onLockedModelSelect={handleLockedModelSelect}
|
||||
onsubmit={handleCreate}
|
||||
/>
|
||||
|
||||
<p class="ui:text-muted-foreground my-3 text-center text-sm font-medium">
|
||||
{$t('course.creator.template.heading')}
|
||||
</p>
|
||||
<div class="grid gap-3 sm:grid-cols-3">
|
||||
{#each COURSE_TEMPLATES as template (template.id)}
|
||||
{@const display = DISPLAY_BY_ID[template.id]}
|
||||
{@const isSelected = selectedTemplateId === template.id}
|
||||
<button
|
||||
type="button"
|
||||
class="group ui:border ui:transition-colors focus-visible:ui:ring-[3px] focus-visible:ui:ring-ring/50 focus-visible:ui:outline-none flex h-full min-h-[168px] w-full cursor-pointer flex-col items-start gap-3 rounded-2xl p-5 text-left sm:p-6 {isSelected
|
||||
? 'ui:bg-primary/5 ui:border-primary ui:ring-primary/20 ui:ring-2'
|
||||
: 'ui:bg-card ui:border-border ui:hover:border-primary'}"
|
||||
onclick={() => selectTemplate(template)}
|
||||
aria-label={$t(display.titleKey)}
|
||||
aria-pressed={isSelected}
|
||||
>
|
||||
<div class="ui:text-primary ui:bg-primary/10 flex size-10 shrink-0 items-center justify-center rounded-lg">
|
||||
{#if display.iconName === 'GraduationCap'}
|
||||
<GraduationCapIcon class="size-6" />
|
||||
{:else if display.iconName === 'Compass'}
|
||||
<CompassIcon class="size-6" />
|
||||
{:else}
|
||||
<AwardIcon class="size-6" />
|
||||
{/if}
|
||||
</div>
|
||||
<span class="ui:text-foreground text-sm leading-tight font-semibold">
|
||||
{$t(display.titleKey)}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground text-xs leading-snug">
|
||||
{$t(display.descriptionKey)}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Tabs from '@cio/ui/custom/underline-tabs';
|
||||
import {
|
||||
CountriesTable,
|
||||
Funnel,
|
||||
LandingKpis,
|
||||
PopularTypes,
|
||||
RangePicker,
|
||||
RefreshButton,
|
||||
TrendChart,
|
||||
analyticsApi
|
||||
} from '$features/analytics';
|
||||
|
||||
onMount(() => {
|
||||
const orgId = $currentOrg.id;
|
||||
if (orgId) analyticsApi.ensureFetched(orgId);
|
||||
});
|
||||
|
||||
function handleRangeChange(days: 7 | 30 | 90) {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
analyticsApi.setRange(days, orgId);
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
analyticsApi.refresh(orgId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('analytics.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('analytics.title')}</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('analytics.subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<RangePicker value={analyticsApi.range} onChange={handleRangeChange} />
|
||||
<RefreshButton loading={analyticsApi.loading} onClick={handleRefresh} />
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="space-y-6">
|
||||
<LandingKpis data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
|
||||
|
||||
<Tabs.Root value="overview">
|
||||
<Tabs.List class="mb-6">
|
||||
<Tabs.Trigger value="overview">{$t('analytics.tabs.overview')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="funnel">{$t('analytics.tabs.funnel')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="geography">{$t('analytics.tabs.geography')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="course_types">{$t('analytics.tabs.course_types')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="overview" class="space-y-4">
|
||||
<TrendChart data={analyticsApi.landing} loading={analyticsApi.loadingLanding} />
|
||||
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
|
||||
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
|
||||
</div>
|
||||
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="funnel">
|
||||
<Funnel data={analyticsApi.funnel} loading={analyticsApi.loadingFunnel} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="geography">
|
||||
<CountriesTable data={analyticsApi.country} loading={analyticsApi.loadingCountry} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="course_types">
|
||||
<PopularTypes data={analyticsApi.popularTypes} loading={analyticsApi.loadingPopularTypes} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Tabs from '@cio/ui/custom/underline-tabs';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Spinner } from '@cio/ui/base/spinner';
|
||||
import RefreshIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import { StatusTiles, CourseBreakdown, LearnersTable, complianceApi } from '$features/compliance';
|
||||
import type { ComplianceLearnerRow } from '$features/compliance/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
const orgId = $currentOrg.id;
|
||||
if (orgId) complianceApi.ensureFetched(orgId);
|
||||
});
|
||||
|
||||
function handleRefresh() {
|
||||
const orgId = $currentOrg.id;
|
||||
if (!orgId) return;
|
||||
|
||||
complianceApi.fetchOverview(orgId);
|
||||
}
|
||||
|
||||
type LearnerStatus = ComplianceLearnerRow['status'];
|
||||
|
||||
const statusFilters: Array<LearnerStatus | 'all'> = [
|
||||
'all',
|
||||
'compliant',
|
||||
'expiring_soon',
|
||||
'in_grace_period',
|
||||
'non_compliant',
|
||||
'in_progress',
|
||||
'not_started',
|
||||
'waived',
|
||||
'no_record'
|
||||
];
|
||||
|
||||
let activeFilter = $state<LearnerStatus | 'all'>('all');
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('compliance.title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('compliance.title')}</Page.Title>
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('compliance.subtitle')}</p>
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<Button variant="outline" size="sm" disabled={complianceApi.loading} onclick={handleRefresh}>
|
||||
<RefreshIcon class={complianceApi.loading ? 'animate-spin' : ''} />
|
||||
{$t('analytics.refresh')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div class="space-y-6">
|
||||
{#if complianceApi.loading && !complianceApi.overview}
|
||||
<div class="flex h-32 items-center justify-center">
|
||||
<Spinner class="ui:text-muted-foreground size-6" />
|
||||
</div>
|
||||
{:else}
|
||||
<StatusTiles data={complianceApi.overview} />
|
||||
|
||||
<Tabs.Root value="courses">
|
||||
<Tabs.List class="mb-6">
|
||||
<Tabs.Trigger value="courses">{$t('compliance.tabs.courses')}</Tabs.Trigger>
|
||||
<Tabs.Trigger value="learners">{$t('compliance.tabs.learners')}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="courses" class="space-y-4">
|
||||
<CourseBreakdown rows={complianceApi.overview?.courses ?? []} />
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="learners" class="space-y-4">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#each statusFilters as filter (filter)}
|
||||
<Button
|
||||
variant={activeFilter === filter ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onclick={() => (activeFilter = filter)}
|
||||
>
|
||||
{$t(`compliance.filter.${filter}`)}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<LearnersTable rows={complianceApi.overview?.learners ?? []} statusFilter={activeFilter} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -25,6 +25,14 @@
|
||||
let selectedOrder = $state<CourseSortOrder>(DEFAULT_SORT_ORDER);
|
||||
|
||||
let selectedTags = $state<string[]>(getInitialSelectedTags());
|
||||
let courseType = $state<string>('all');
|
||||
|
||||
const courseTypeOptions = $derived([
|
||||
{ value: 'SELF_PACED', label: $t('new_course_modal.self_paced_label') },
|
||||
{ value: 'LIVE_CLASS', label: $t('new_course_modal.live_class_label') },
|
||||
{ value: 'COMPLIANCE', label: $t('new_course_modal.compliance_label') },
|
||||
{ value: 'PUBLIC', label: $t('new_course_modal.public_label') }
|
||||
]);
|
||||
|
||||
let hasInitializedFilters = $state(false);
|
||||
let hasCompletedInitialUrlSync = false;
|
||||
@@ -61,6 +69,12 @@
|
||||
nextUrl.searchParams.delete('order');
|
||||
}
|
||||
|
||||
if (courseType !== 'all') {
|
||||
nextUrl.searchParams.set('type', courseType);
|
||||
} else {
|
||||
nextUrl.searchParams.delete('type');
|
||||
}
|
||||
|
||||
const nextPath = `${nextUrl.pathname}${nextUrl.search}${nextUrl.hash}`;
|
||||
window.history.replaceState(window.history.state, '', nextPath);
|
||||
}
|
||||
@@ -107,21 +121,30 @@
|
||||
async function clearFilters() {
|
||||
sortKey = DEFAULT_COURSE_SORT;
|
||||
selectedOrder = DEFAULT_SORT_ORDER;
|
||||
courseType = 'all';
|
||||
|
||||
if (selectedTags.length === 0) {
|
||||
updateFiltersUrl();
|
||||
return;
|
||||
}
|
||||
|
||||
await applyTagFilters([]);
|
||||
}
|
||||
|
||||
function setCourseType(nextType: string) {
|
||||
courseType = nextType;
|
||||
updateFiltersUrl();
|
||||
}
|
||||
|
||||
const filteredCourses = $derived.by(() => {
|
||||
const filteredCourses = (coursesApi.orgCourses ?? []).filter((course) => {
|
||||
if (!searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
const matchesSearch = !searchValue || course.title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
|
||||
return false;
|
||||
if (!matchesSearch) return false;
|
||||
|
||||
if (courseType !== 'all' && course.type !== courseType) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const sortedCourses = [...filteredCourses];
|
||||
@@ -169,6 +192,11 @@
|
||||
selectedOrder = orderFromStorage;
|
||||
}
|
||||
|
||||
const typeFromUrl = searchParams.get('type');
|
||||
if (typeFromUrl && courseTypeOptions.some((opt) => opt.value === typeFromUrl)) {
|
||||
courseType = typeFromUrl;
|
||||
}
|
||||
|
||||
hasInitializedFilters = true;
|
||||
});
|
||||
</script>
|
||||
@@ -194,10 +222,13 @@
|
||||
<CourseFilterPopover
|
||||
bind:sortKey
|
||||
bind:selectedOrder
|
||||
bind:courseType
|
||||
{courseTypeOptions}
|
||||
{selectedTags}
|
||||
tagGroups={data.tagGroups}
|
||||
{isFiltering}
|
||||
onToggleTag={toggleTag}
|
||||
onCourseTypeChange={setCourseType}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
@@ -8,13 +8,8 @@ const cache: Record<string, DashStatsSuccess['data'] | null> = {};
|
||||
function loginActivityDataFromSettled(
|
||||
result: PromiseSettledResult<ServerApiResult<LoginActivitySuccess>>
|
||||
): LoginActivityData {
|
||||
if (result.status === 'rejected') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!result.value.ok) {
|
||||
return [];
|
||||
}
|
||||
if (result.status === 'rejected') return [];
|
||||
if (!result.value.ok) return [];
|
||||
|
||||
return result.value.body.data;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { OrgAiTutorSettingsPage } from '$features/ai-tutor-settings';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>AI Tutor Settings - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<OrgAiTutorSettingsPage />
|
||||
@@ -0,0 +1,13 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<Page.Root class="w-full pb-10">
|
||||
{@render children?.()}
|
||||
</Page.Root>
|
||||
@@ -2,21 +2,7 @@
|
||||
import { LandingpagePage } from '$features/settings/pages';
|
||||
import { VisitOrgSiteButton } from '$features/ui';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
let landingPageComponent: LandingpagePage | null = $state(null);
|
||||
let hasUnsavedChanges = $state(false);
|
||||
let isSaving = $state(false);
|
||||
|
||||
async function handleSave() {
|
||||
isSaving = true;
|
||||
try {
|
||||
await landingPageComponent?.handleSave();
|
||||
} finally {
|
||||
isSaving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -30,14 +16,10 @@
|
||||
</Page.HeaderContent>
|
||||
<Page.Action>
|
||||
<VisitOrgSiteButton labelKey="markdown_editor.preview" variant="secondary" />
|
||||
|
||||
<Button variant="default" loading={isSaving} disabled={isSaving || !hasUnsavedChanges} onclick={handleSave}>
|
||||
{$t('settings.landing_page.save_changes')}
|
||||
</Button>
|
||||
</Page.Action>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<LandingpagePage bind:this={landingPageComponent} bind:hasUnsavedChanges />
|
||||
<LandingpagePage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
|
||||
@@ -1,26 +1,67 @@
|
||||
<script lang="ts">
|
||||
import { fly } from 'svelte/transition';
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount, type Component } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { currentOrg, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { landingPageSettings } from '$features/settings/utils/store';
|
||||
import { landingPageSettings, landingPageEditorSelection } from '$features/settings/utils/store';
|
||||
import { buildOrgLandingPageProps, normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
import { landingPageThemeComponents } from '$features/org/utils/landing-page-components';
|
||||
import { landingPageThemes } from '$features/org/utils/landing-page';
|
||||
import LandingpageEditor from '$features/settings/pages/landingpage-editor.svelte';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { setLandingPageEditContext, type LandingSectionKey } from '@cio/ui/custom/org-landing-page';
|
||||
import {
|
||||
ContentIcon,
|
||||
ExploreIcon,
|
||||
ExternalLinkIcon,
|
||||
GoalIcon,
|
||||
HeaderIcon,
|
||||
SettingsIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
const sectionIcons: Record<LandingSectionKey, Component> = {
|
||||
navigation: ContentIcon,
|
||||
hero: HeaderIcon,
|
||||
links: ExploreIcon,
|
||||
embed: ExternalLinkIcon,
|
||||
callout: GoalIcon,
|
||||
footer: SettingsIcon
|
||||
};
|
||||
|
||||
// Reset selection on every mount so the editor opens to the section list.
|
||||
landingPageEditorSelection.set(null);
|
||||
|
||||
// Mirror the store into a $state so the preview's wrappers ($derived) react
|
||||
// to selection changes. Reading store via get() inside an arrow is non-reactive.
|
||||
let selectedSectionKey = $state<LandingSectionKey | null>(null);
|
||||
$effect(() => landingPageEditorSelection.subscribe((value) => (selectedSectionKey = value)));
|
||||
|
||||
setLandingPageEditContext({
|
||||
selectedKey: () => selectedSectionKey,
|
||||
selectKey: (key) => landingPageEditorSelection.set(key),
|
||||
labelFor: (key) => t.get(`settings.landing_page.editor.sections.${key}`),
|
||||
iconFor: (key) => sectionIcons[key]
|
||||
});
|
||||
|
||||
function handlePreviewClick(event: MouseEvent) {
|
||||
if (!(event.target instanceof Element)) return;
|
||||
if (event.target.closest('[data-landing-section]')) return;
|
||||
landingPageEditorSelection.set(null);
|
||||
}
|
||||
|
||||
let sidebarOpen = $state(true);
|
||||
let hasInitialized = $state(false);
|
||||
|
||||
function isLandingPageTheme(value: string | null): value is 'minimal' | 'bold' | 'classic' {
|
||||
return value === 'minimal' || value === 'bold' || value === 'classic';
|
||||
function isLandingPageTheme(value: string | null): value is (typeof landingPageThemes)[number] {
|
||||
if (value === null) return false;
|
||||
return (landingPageThemes as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
const savedLandingPageSettings = $derived(normalizeLandingPageSettings($currentOrg.landingpage));
|
||||
@@ -94,14 +135,14 @@
|
||||
in:fly={{ y: 500, duration: 500 }}
|
||||
out:fly={{ y: 500, duration: 500 }}
|
||||
>
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 280px">
|
||||
<Sidebar.Provider bind:open={sidebarOpen} style="--sidebar-width: 360px; --sidebar-width-icon: 4rem">
|
||||
<Sidebar.Root side="left" collapsible="icon" class="h-full">
|
||||
<LandingpageEditor
|
||||
bind:settings={$landingPageSettings}
|
||||
onSave={async () => {
|
||||
const updatedLandingPage = normalizeLandingPageSettings({
|
||||
...$landingPageSettings,
|
||||
theme: page.url.searchParams.get('theme') ? savedLandingPageSettings.theme : $landingPageSettings.theme
|
||||
theme: previewTheme
|
||||
});
|
||||
|
||||
await orgApi.update($currentOrg.id, {
|
||||
@@ -114,6 +155,7 @@
|
||||
|
||||
<Sidebar.Inset
|
||||
class="relative h-screen! w-[calc(100vw-var(--sidebar-width))] overflow-y-auto group-data-[collapsible=icon]:w-[calc(100vw-var(--sidebar-width-icon))]"
|
||||
onclick={handlePreviewClick}
|
||||
>
|
||||
<div class="absolute top-2 left-2 z-60">
|
||||
<Sidebar.Trigger variant="secondary" />
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
|
||||
import { WorkspacesPage } from '$features/account';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('account.workspaces.page_title')} - ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('account.workspaces.page_title')}</Page.Title>
|
||||
<Page.Subtitle>{$t('account.workspaces.page_description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<WorkspacesPage />
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
@@ -0,0 +1,147 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Page from '@cio/ui/base/page';
|
||||
import * as Table from '@cio/ui/base/table';
|
||||
import { Badge } from '@cio/ui/base/badge';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { GoalIcon } from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { programGoalApi } from '$features/program/api';
|
||||
import type { GoalsOverviewRow } from '$features/program/utils/types';
|
||||
|
||||
onMount(() => {
|
||||
programGoalApi.listOverview();
|
||||
});
|
||||
|
||||
const overviewRows = $derived(programGoalApi.overviewRows);
|
||||
|
||||
const programs = $derived.by(() => {
|
||||
const map = new Map<string, { programId: string; programName: string; goals: GoalsOverviewRow[] }>();
|
||||
|
||||
for (const row of overviewRows) {
|
||||
const bucket = map.get(row.programId) ?? {
|
||||
programId: row.programId,
|
||||
programName: row.programName,
|
||||
goals: []
|
||||
};
|
||||
bucket.goals.push(row);
|
||||
map.set(row.programId, bucket);
|
||||
}
|
||||
|
||||
return [...map.values()].sort((a, b) => a.programName.localeCompare(b.programName));
|
||||
});
|
||||
|
||||
function aggregateOnTrackPct(rows: GoalsOverviewRow[]): number {
|
||||
if (rows.length === 0) return 0;
|
||||
const total = rows.reduce((sum, row) => sum + row.totalLearners, 0);
|
||||
if (total === 0) return 0;
|
||||
const onTrack = rows.reduce((sum, row) => sum + (row.totalLearners - row.atRiskCount - row.overdueCount), 0);
|
||||
return Math.round((onTrack / total) * 100);
|
||||
}
|
||||
|
||||
function aggregateCount(rows: GoalsOverviewRow[], field: keyof GoalsOverviewRow): number {
|
||||
return rows.reduce((sum, row) => sum + Number(row[field] ?? 0), 0);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('programs.goals.heading')} · ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<Page.Root class="w-full">
|
||||
<Page.Header>
|
||||
<Page.HeaderContent>
|
||||
<Page.Title>{$t('programs.goals.heading')}</Page.Title>
|
||||
<Page.Subtitle>{$t('programs.goals.description')}</Page.Subtitle>
|
||||
</Page.HeaderContent>
|
||||
</Page.Header>
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
{#if !programGoalApi.isLoading && overviewRows.length === 0}
|
||||
<Empty
|
||||
title={$t('programs.goals.empty_title')}
|
||||
description={$t('programs.goals.empty_description')}
|
||||
icon={GoalIcon}
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex w-full flex-col gap-6 p-4">
|
||||
{#each programs as program (program.programId)}
|
||||
{@const onTrackPct = aggregateOnTrackPct(program.goals)}
|
||||
{@const totalLearners = aggregateCount(program.goals, 'totalLearners')}
|
||||
{@const overdueLearners = aggregateCount(program.goals, 'overdueCount')}
|
||||
{@const atRiskLearners = aggregateCount(program.goals, 'atRiskCount')}
|
||||
<section class="flex flex-col gap-3 rounded-md border p-4">
|
||||
<header class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<a
|
||||
href={resolve(`/programs/${program.programId}/settings`, {})}
|
||||
class="text-base font-semibold hover:underline"
|
||||
>
|
||||
{program.programName}
|
||||
</a>
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs">
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="text-base font-semibold text-emerald-600">{onTrackPct}%</span>
|
||||
{$t('programs.goals.tile.on_track')}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground">
|
||||
<span class="font-medium">{totalLearners}</span> learners
|
||||
</span>
|
||||
{#if atRiskLearners > 0}
|
||||
<span class="text-amber-700">
|
||||
<span class="font-medium">{atRiskLearners}</span>
|
||||
{$t('programs.goals.tile.at_risk_short')}
|
||||
</span>
|
||||
{/if}
|
||||
{#if overdueLearners > 0}
|
||||
<span class="text-red-600">
|
||||
<span class="font-medium">{overdueLearners}</span>
|
||||
{$t('programs.goals.tile.overdue_short')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="rounded-md border">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Goal</Table.Head>
|
||||
<Table.Head>Type</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.completed')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.in_progress')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.at_risk')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.status.overdue')}</Table.Head>
|
||||
<Table.Head>{$t('programs.goals.tile.on_track')}</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each program.goals as goal (goal.goalId)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{goal.title}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge variant="outline">{$t(`programs.goals.type.${goal.type}`)}</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.completedCount}/{goal.totalLearners}</Table.Cell>
|
||||
<Table.Cell>{goal.inProgressCount}</Table.Cell>
|
||||
<Table.Cell class={goal.atRiskCount > 0 ? 'text-amber-700' : ''}>
|
||||
{goal.atRiskCount}
|
||||
</Table.Cell>
|
||||
<Table.Cell class={goal.overdueCount > 0 ? 'text-red-600' : ''}>
|
||||
{goal.overdueCount}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{goal.onTrackPct}%</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Page.Body>
|
||||
</Page.Root>
|
||||
@@ -6,6 +6,7 @@
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { NewsFeedLoader } from '$features/course/components/newsfeed';
|
||||
import { NewFeedModal, NewsFeedCard } from '$features/program/components/newsfeed';
|
||||
import MyGoalsWidget from '$features/program/components/goals/my-goals-widget.svelte';
|
||||
import { programApi, programNewsfeedApi } from '$features/program/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
@@ -126,6 +127,8 @@
|
||||
{#snippet child()}
|
||||
<NewFeedModal {programId} bind:edit bind:editFeed />
|
||||
|
||||
<MyGoalsWidget {programId} />
|
||||
|
||||
{#if isListing}
|
||||
<div class="flex w-full flex-col items-center">
|
||||
<NewsFeedLoader />
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import InviteMembersModal from '$features/program/components/invite-members-modal.svelte';
|
||||
import GoalsOverviewTiles from '$features/program/components/goals/goals-overview-tiles.svelte';
|
||||
import { programApi } from '$features/program/api';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -147,6 +148,8 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<GoalsOverviewTiles programId={data.programId} />
|
||||
|
||||
<section class="space-y-2">
|
||||
<div class="flex flex-col items-center justify-end gap-2 md:flex-row">
|
||||
<Search placeholder={$t('course.navItem.people.search')} bind:value={searchValue} />
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
import { programApi } from '$features/program/api';
|
||||
import { goto } from '$app/navigation';
|
||||
import { resolve } from '$app/paths';
|
||||
import { currentOrgPath } from '$lib/utils/store/org';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import GoalsSection from '$features/program/components/goals/goals-section.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const programId = data.programId as string;
|
||||
@@ -29,6 +32,11 @@
|
||||
}
|
||||
});
|
||||
|
||||
const currentMemberRole = $derived(programApi.members.find((m) => m.profileId === $profile.id)?.roleId ?? null);
|
||||
const canManageGoals = $derived(
|
||||
Boolean($isOrgAdmin) || currentMemberRole === ROLE.ADMIN || currentMemberRole === ROLE.TUTOR
|
||||
);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'ACTIVE', label: $t('programs.status.active') || 'Active' },
|
||||
{ value: 'INACTIVE', label: $t('programs.status.inactive') || 'Inactive' },
|
||||
@@ -121,6 +129,10 @@
|
||||
|
||||
<Separator />
|
||||
|
||||
<GoalsSection {programId} canManage={canManageGoals} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<section>
|
||||
<h2 class="mb-1 text-sm font-semibold text-red-600">
|
||||
{$t('programs.settings.danger_zone') || 'Danger Zone'}
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
<script lang="ts">
|
||||
import { PageRestricted } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { onMount } from 'svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
import { env } from '$env/dynamic/public';
|
||||
import { analytics } from '@cio/analytics/client';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
children?: Snippet;
|
||||
data: {
|
||||
isOrgSite: boolean;
|
||||
org: import('$features/app/types').AccountOrg | null;
|
||||
org: AccountOrg | null;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
if (!env.PUBLIC_SERVER_URL) return;
|
||||
|
||||
analytics.init({
|
||||
endpoint: `${env.PUBLIC_SERVER_URL.replace(/\/$/, '')}/dash/track`,
|
||||
orgId: data.org?.id ?? undefined
|
||||
});
|
||||
analytics.pageView({ kind: 'landing' });
|
||||
});
|
||||
|
||||
afterNavigate(({ to }) => {
|
||||
if (!to) return;
|
||||
|
||||
const isCoursePage = /\/course\//.test(to.url.pathname);
|
||||
analytics.pageView({
|
||||
kind: isCoursePage ? 'course' : 'landing',
|
||||
path: to.url.pathname
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
|
||||
@@ -11,7 +11,7 @@ type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>,
|
||||
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
|
||||
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
export const load = async ({ params = { slug: '' }, parent, url }) => {
|
||||
const { org: parentOrg } = await parent();
|
||||
|
||||
// PUBLIC courses are served by the anonymous org-site endpoint and bypass the
|
||||
@@ -67,18 +67,23 @@ export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalUrl = new URL(url.pathname, url.origin).href;
|
||||
|
||||
const pageMetaTags = Object.freeze({
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
canonical: canonicalUrl,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
url: canonicalUrl,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
images: [
|
||||
{
|
||||
url: course.logo || '',
|
||||
alt: course.title,
|
||||
width: 280,
|
||||
height: 200,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
secureUrl: course.logo,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
@@ -91,7 +96,7 @@ export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
image: course.logo,
|
||||
imageAlt: 'ClassroomIO OG Image'
|
||||
imageAlt: course.title
|
||||
}
|
||||
}) satisfies MetaTagsProps;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
@@ -57,6 +58,25 @@
|
||||
const orgName = $derived(data.org?.name ?? data.course?.org?.name ?? 'ClassroomIO');
|
||||
const logoUrl = $derived(data.org?.avatarUrl ?? undefined);
|
||||
|
||||
const courseJsonLd = $derived.by(() => {
|
||||
if (!data.course) return null;
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Course',
|
||||
name: data.course.title,
|
||||
description: data.course.description || '',
|
||||
provider: {
|
||||
'@type': 'Organization',
|
||||
name: orgName
|
||||
},
|
||||
url: page.url.href,
|
||||
...(data.course.logo ? { image: data.course.logo } : {})
|
||||
};
|
||||
|
||||
return JSON.stringify(schema).replace(/</g, '\\u003c');
|
||||
});
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let NavComponent = $state<Component<any> | null>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -83,6 +103,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
{#if courseJsonLd}
|
||||
{@html `<script type="application/ld+json">${courseJsonLd}</script>`}
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
{#if data.course}
|
||||
<PoweredBy />
|
||||
|
||||
@@ -102,12 +128,6 @@
|
||||
|
||||
<CourseLandingPage courseData={data.course} />
|
||||
|
||||
<OrgLandingPageFooter
|
||||
{orgName}
|
||||
{logoUrl}
|
||||
footerLinks={landingSettings.footerLinks}
|
||||
footerText={landingSettings.footerText}
|
||||
variant={landingSettings.theme}
|
||||
/>
|
||||
<OrgLandingPageFooter {orgName} {logoUrl} footer={landingSettings.footer} variant={landingSettings.theme} />
|
||||
</main>
|
||||
{/if}
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
|
||||
<svelte:head>
|
||||
<title>Join {data.course?.title ?? 'Course'} on ClassroomIO</title>
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<AuthUI isLogin={false} {handleSubmit} isLoading={loading || !$profile.id} showOnlyContent={true} showLogo={true}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params = { slug: '' } }) => {
|
||||
const treeResult = await safeServerApi<GetPublicCourseSuccess>(() =>
|
||||
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
|
||||
);
|
||||
|
||||
if (!treeResult.ok) {
|
||||
throw error(404, 'Public course not found');
|
||||
}
|
||||
|
||||
return {
|
||||
tree: treeResult.body.data
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { PublicCourse } from '@cio/ui';
|
||||
import { toPublicSidebarSections } from '$features/course/utils/public-course-mappers';
|
||||
import type { PublicCourseSidebarItem, PublicCourseSidebarSection } from '@cio/ui/custom/public-course';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { data, children }: { data: { tree: any }; children?: Snippet } = $props();
|
||||
|
||||
const sections: PublicCourseSidebarSection[] = $derived(toPublicSidebarSections(data.tree));
|
||||
const flatItems = $derived(sections.flatMap((section) => section.items));
|
||||
|
||||
const itemSlug = $derived(page.params.itemSlug ?? '');
|
||||
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 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));
|
||||
}
|
||||
</script>
|
||||
|
||||
{#key data.slug}
|
||||
<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)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</PublicCourse.PublicCourseShell>
|
||||
{/key}
|
||||
+33
-24
@@ -4,31 +4,24 @@ 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()
|
||||
)
|
||||
)
|
||||
]);
|
||||
export const load = async ({ params = { slug: '', itemSlug: '' }, url, parent }) => {
|
||||
const { tree } = await parent();
|
||||
|
||||
if (!treeResult.ok || !itemResult.ok) {
|
||||
const itemResult = await safeServerApi<GetPublicCourseItemSuccess>(() =>
|
||||
classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].$get(
|
||||
{ param: { courseSlug: params.slug, itemSlug: params.itemSlug } },
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
);
|
||||
|
||||
if (!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;
|
||||
@@ -37,29 +30,45 @@ export const load = async ({ params = { slug: '', itemSlug: '' } }) => {
|
||||
? item.body.replace(/<[^>]+>/g, '').slice(0, 155)
|
||||
: (tree.course.description ?? '');
|
||||
|
||||
const canonicalUrl = new URL(url.pathname, url.origin).href;
|
||||
const fullTitle = `${itemTitle} · ${tree.course.title}`;
|
||||
const description = itemSummary || tree.course.description || '';
|
||||
const ogImage = tree.course.bannerImage || null;
|
||||
|
||||
const pageMetaTags = Object.freeze({
|
||||
title: `${itemTitle} · ${tree.course.title}`,
|
||||
description: itemSummary || tree.course.description || '',
|
||||
title: fullTitle,
|
||||
description,
|
||||
canonical: canonicalUrl,
|
||||
openGraph: {
|
||||
type: 'article',
|
||||
url: canonicalUrl,
|
||||
title: itemTitle,
|
||||
description: itemSummary || tree.course.description || '',
|
||||
images: tree.course.bannerImage
|
||||
description,
|
||||
images: ogImage
|
||||
? [
|
||||
{
|
||||
url: tree.course.bannerImage,
|
||||
url: ogImage,
|
||||
alt: tree.course.title,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
secureUrl: tree.course.bannerImage,
|
||||
secureUrl: ogImage,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
]
|
||||
: []
|
||||
},
|
||||
twitter: {
|
||||
handle: '@classroomio',
|
||||
site: '@classroomio',
|
||||
cardType: 'summary_large_image' as const,
|
||||
title: fullTitle,
|
||||
description,
|
||||
image: ogImage ?? undefined,
|
||||
imageAlt: tree.course.title
|
||||
}
|
||||
}) satisfies MetaTagsProps;
|
||||
|
||||
return {
|
||||
tree,
|
||||
item,
|
||||
pageMetaTags
|
||||
};
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { PublicCourse } from '@cio/ui';
|
||||
import {
|
||||
toPublicExerciseView,
|
||||
toPublicLessonView,
|
||||
toPublicSidebarSections
|
||||
} from '$features/course/utils/public-course-mappers';
|
||||
import { toPublicExerciseView, toPublicLessonView } from '$features/course/utils/public-course-mappers';
|
||||
import {
|
||||
publicExerciseAttemptsStorageKey,
|
||||
type PublicCourseSidebarItem,
|
||||
type PublicCourseSidebarSection,
|
||||
type PublicLessonViewData,
|
||||
type PublicExerciseViewData
|
||||
} from '@cio/ui/custom/public-course';
|
||||
@@ -20,26 +14,8 @@
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const sections: PublicCourseSidebarSection[] = $derived(toPublicSidebarSections(data.tree));
|
||||
const flatItems = $derived(sections.flatMap((section) => section.items));
|
||||
|
||||
const callout = $derived(data?.tree?.course.callout ?? null);
|
||||
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
|
||||
@@ -47,52 +23,61 @@
|
||||
const exerciseView = $derived<PublicExerciseViewData | null>(
|
||||
data.item.kind === 'exercise' ? toPublicExerciseView(data.item) : null
|
||||
);
|
||||
|
||||
const breadcrumbJsonLd = $derived.by(() => {
|
||||
const courseUrl = new URL(`/course/${data?.tree.course.slug}`, page.url.origin).href;
|
||||
const itemTitle = 'title' in data.item ? data.item.title : data?.tree.course.title;
|
||||
|
||||
const schema = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: data?.tree.course.title,
|
||||
item: courseUrl
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: itemTitle,
|
||||
item: page.url.href
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return JSON.stringify(schema).replace(/</g, '\\u003c');
|
||||
});
|
||||
</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}
|
||||
<svelte:head>
|
||||
{#if breadcrumbJsonLd}
|
||||
{@html `<script type="application/ld+json">${breadcrumbJsonLd}</script>`}
|
||||
{/if}
|
||||
</PublicCourse.PublicCourseShell>
|
||||
</svelte:head>
|
||||
|
||||
{#key data.item.id}
|
||||
{#if lessonView}
|
||||
<PublicCourse.PublicLessonView
|
||||
lesson={lessonView}
|
||||
videoCaptionsLabel={$t('course.navItem.lessons.materials.tabs.video.transcript.captions_label')}
|
||||
{callout}
|
||||
/>
|
||||
{:else if exerciseView}
|
||||
<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')}
|
||||
/>
|
||||
{/if}
|
||||
{/key}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
@@ -49,12 +50,40 @@ export const load = async ({ parent, url }) => {
|
||||
)
|
||||
]);
|
||||
const courseData = coursesResult.ok ? coursesResult.body.data : { courses: [], hasMoreCourses: false };
|
||||
const canonicalUrl = new URL(url.pathname, url.origin).href;
|
||||
const orgTitle = `${org.name} – All Courses`;
|
||||
const orgDescription = `Browse all courses offered by ${org.name}`;
|
||||
|
||||
const pageMetaTags = Object.freeze({
|
||||
title: orgTitle,
|
||||
description: orgDescription,
|
||||
canonical: canonicalUrl,
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
url: canonicalUrl,
|
||||
title: orgTitle,
|
||||
description: orgDescription,
|
||||
...(org.avatarUrl
|
||||
? {
|
||||
images: [
|
||||
{
|
||||
url: org.avatarUrl,
|
||||
alt: `${org.name} logo`,
|
||||
secureUrl: org.avatarUrl,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
]
|
||||
}
|
||||
: {})
|
||||
}
|
||||
} satisfies MetaTagsProps);
|
||||
|
||||
return {
|
||||
org,
|
||||
courses: courseData.courses,
|
||||
hasMoreCourses: courseData.hasMoreCourses,
|
||||
tagGroups: tagsResult.ok ? tagsResult.body.data : [],
|
||||
activeTags: normalizedTags
|
||||
activeTags: normalizedTags,
|
||||
pageMetaTags
|
||||
};
|
||||
};
|
||||
|
||||
@@ -117,10 +117,6 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('public_courses.page_title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<PoweredBy />
|
||||
|
||||
<main class={shellClass}>
|
||||
@@ -227,8 +223,7 @@
|
||||
<OrgLandingPageFooter
|
||||
orgName={data.org.name}
|
||||
logoUrl={data.org.avatarUrl ?? undefined}
|
||||
footerLinks={landingSettings.footerLinks}
|
||||
footerText={landingSettings.footerText}
|
||||
footer={landingSettings.footer}
|
||||
variant={landingSettings.theme}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { appInitApi } from '$features/app/init.svelte';
|
||||
import { setupCloudAnalytics } from '$lib/utils/functions/appSetup';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, mergeAccountOrgFromServer } from '$lib/utils/store/org';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { authClient } from '$lib/utils/services/auth/client';
|
||||
@@ -39,7 +39,7 @@
|
||||
if (data.isOrgSite && data.org) {
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = true;
|
||||
currentOrg.set(data.org);
|
||||
currentOrg.set(mergeAccountOrgFromServer(data.org));
|
||||
setTheme(data.org.theme || 'blue');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -54,7 +54,13 @@
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
if (!data.isOrgSite || !data.org) {
|
||||
if (!appInitApi.loading) {
|
||||
appInitApi.routeUserToNextPage({ isOrgSite: false, orgSiteName: '' });
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = normalizeLandingPageSettings(data.org.landingpage);
|
||||
const mod = await importThemeComponent(settings.theme);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { AuthUI } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, mergeAccountOrgFromServer } from '$lib/utils/store/org';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
@@ -114,7 +114,7 @@
|
||||
if (!data.currentOrg) return;
|
||||
|
||||
setTheme(data.currentOrg?.theme || '');
|
||||
currentOrg.set(data.currentOrg);
|
||||
currentOrg.set(mergeAccountOrgFromServer(data.currentOrg));
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user