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
@@ -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}
@@ -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>
+2 -2
View File
@@ -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');
}
});
+7 -1
View File
@@ -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>