chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
@@ -1,15 +1,30 @@
import type { MetaTagsProps } from 'svelte-meta-tags';
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
import type { GetOrganizationRequest } from '$features/org/utils/types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { redirect } from '@sveltejs/kit';
type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
export const load = async ({ params = { slug: '' }, parent }) => {
const { org: parentOrg } = await parent();
// PUBLIC courses are served by the anonymous org-site endpoint and bypass the
// regular landing page entirely — redirect straight to the first item.
const publicTreeResult = await safeServerApi<GetPublicCourseSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
);
if (publicTreeResult.ok && publicTreeResult.body.data.items.length > 0) {
const firstItem = publicTreeResult.body.data.items[0]!;
throw redirect(302, `/course/${params.slug}/lesson/${firstItem.slug}`);
}
const courseResult = await safeServerApi<GetCourseBySlugSuccess>(() =>
classroomio.course.slug[':slug'].$get(
{
@@ -30,14 +45,16 @@ export const load = async ({ params = { slug: '' }, parent }) => {
};
}
const course = courseResult.body.data;
const course = courseResult.body.data as CourseBySlugWithOrg;
let org = parentOrg ?? null;
if (!org && course.org?.siteName) {
const courseOrgSiteName = course.org?.siteName;
if (!org && courseOrgSiteName) {
const organizationResult = await safeServerApi<GetOrganizationSuccess>(() =>
classroomio.organization.$get(
{
query: { siteName: course.org.siteName }
query: { siteName: courseOrgSiteName }
},
getApiKeyHeaders()
)
@@ -7,7 +7,7 @@
import { user } from '$lib/utils/store/user';
import { CourseLandingPage } from '$features/ui';
import { PoweredBy } from '$features/ui';
import { normalizeLandingPageSettings, importThemeComponent } from '$features/org/utils/landing-page';
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
import { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page';
import type { Component } from 'svelte';
@@ -39,13 +39,16 @@
return landingSettings.hero;
}
const enrollmentsOpen = data.course.metadata?.allowNewStudent === true;
return {
...landingSettings.hero,
heading: data.course.title,
subheading: data.course.description || landingSettings.hero.subheading,
primaryAction: {
label: t.get('course.navItem.landing_page.start_course'),
href: resolve(`/course/${data.course.slug}/enroll`, {})
href: resolve(`/course/${data.course.slug}/enroll`, {}),
disabled: !enrollmentsOpen
},
image: data.course.logo || landingSettings.hero.image
};
@@ -97,7 +100,7 @@
{/if}
{/if}
<CourseLandingPage courseData={data.course} showStandaloneShell={false} />
<CourseLandingPage courseData={data.course} />
<OrgLandingPageFooter
{orgName}
@@ -1,4 +1,4 @@
import type { GetCourseBySlugRequest } from '$features/course/utils/types';
import type { CourseBySlugWithOrg, GetCourseBySlugRequest } from '$features/course/utils/types';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { calcCourseDiscount } from '$lib/utils/functions/course';
@@ -66,7 +66,7 @@ export const load = async ({ params, url }) => {
throw error(404, 'Not found');
}
const courseData = courseResult.body.data;
const courseData = courseResult.body.data as CourseBySlugWithOrg;
const discount = (courseData.metadata as { discount?: number } | null)?.discount ?? 0;
const showDiscount = (courseData.metadata as { showDiscount?: boolean } | null)?.showDiscount ?? false;
const calculatedCost = calcCourseDiscount(discount, Number(courseData.cost ?? 0), showDiscount);
@@ -103,13 +103,17 @@
$effect(() => {
if (!data.currentOrg) return;
setTheme(data.currentOrg?.theme);
const rawTheme = data.currentOrg.theme;
const themeString = typeof rawTheme === 'string' ? rawTheme : '';
setTheme(themeString);
currentOrg.update((o) => ({
...o,
id: data.currentOrg?.id ?? o.id,
name: data.currentOrg?.name ?? o.name,
siteName: data.currentOrg?.siteName ?? o.siteName,
theme: data.currentOrg?.theme ?? o.theme
theme: themeString || o.theme
}));
});
</script>
@@ -0,0 +1,66 @@
import type { MetaTagsProps } from 'svelte-meta-tags';
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
import { error } from '@sveltejs/kit';
type GetPublicCourseRequest = (typeof classroomio)['org-site']['course'][':courseSlug']['$get'];
type GetPublicCourseItemRequest =
(typeof classroomio)['org-site']['course'][':courseSlug']['item'][':itemSlug']['$get'];
type GetPublicCourseSuccess = Extract<InferResponseType<GetPublicCourseRequest>, { success: true }>;
type GetPublicCourseItemSuccess = Extract<InferResponseType<GetPublicCourseItemRequest>, { success: true }>;
export const load = async ({ params = { slug: '', itemSlug: '' } }) => {
const [treeResult, itemResult] = await Promise.all([
safeServerApi<GetPublicCourseSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].$get({ param: { courseSlug: params.slug } }, getApiKeyHeaders())
),
safeServerApi<GetPublicCourseItemSuccess>(() =>
classroomio['org-site'].course[':courseSlug'].item[':itemSlug'].$get(
{ param: { courseSlug: params.slug, itemSlug: params.itemSlug } },
getApiKeyHeaders()
)
)
]);
if (!treeResult.ok || !itemResult.ok) {
throw error(404, 'Public course or lesson not found');
}
const tree = treeResult.body.data;
const item = itemResult.body.data;
const itemTitle = 'title' in item ? item.title : tree.course.title;
const itemSummary =
item.kind === 'lesson' && typeof item.body === 'string'
? item.body.replace(/<[^>]+>/g, '').slice(0, 155)
: (tree.course.description ?? '');
const pageMetaTags = Object.freeze({
title: `${itemTitle} · ${tree.course.title}`,
description: itemSummary || tree.course.description || '',
openGraph: {
title: itemTitle,
description: itemSummary || tree.course.description || '',
images: tree.course.bannerImage
? [
{
url: tree.course.bannerImage,
alt: tree.course.title,
width: 1200,
height: 630,
secureUrl: tree.course.bannerImage,
type: 'image/jpeg'
}
]
: []
}
}) satisfies MetaTagsProps;
return {
tree,
item,
pageMetaTags
};
};
@@ -0,0 +1,98 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { PublicCourse } from '@cio/ui';
import {
toPublicExerciseView,
toPublicLessonView,
toPublicSidebarSections
} from '$features/course/utils/public-course-mappers';
import {
publicExerciseAttemptsStorageKey,
type PublicCourseSidebarItem,
type PublicCourseSidebarSection,
type PublicLessonViewData,
type PublicExerciseViewData
} from '@cio/ui/custom/public-course';
import { getExerciseQuestionLabels } from '$features/course/components/exercise/question-labels';
import { t } from '$lib/utils/functions/translations';
const exerciseLabels = $derived(getExerciseQuestionLabels());
let { data } = $props();
const sections: PublicCourseSidebarSection[] = $derived(toPublicSidebarSections(data.tree));
const flatItems = $derived(sections.flatMap((section) => section.items));
const itemSlug = $derived(data.item.slug);
const activeIndex = $derived(flatItems.findIndex((entry) => entry.slug === itemSlug));
const activeItem = $derived<PublicCourseSidebarItem | null>(flatItems[activeIndex] ?? null);
const prevItem = $derived(activeIndex > 0 ? flatItems[activeIndex - 1] : null);
const nextItem = $derived(activeIndex >= 0 && activeIndex < flatItems.length - 1 ? flatItems[activeIndex + 1] : null);
const callout = $derived(data.tree.course.callout ?? null);
const courseTitle = $derived(data.tree.course.title);
const org = $derived(data.tree.course.org ?? null);
const hrefFor = (item: PublicCourseSidebarItem) => `/course/${data.tree.course.slug}/lesson/${item.slug}`;
async function navigateTo(item: PublicCourseSidebarItem | null) {
if (!item) return;
await goto(hrefFor(item));
}
const lessonView = $derived<PublicLessonViewData | null>(
data.item.kind === 'lesson' ? toPublicLessonView(data.item) : null
);
const exerciseView = $derived<PublicExerciseViewData | null>(
data.item.kind === 'exercise' ? toPublicExerciseView(data.item) : null
);
</script>
<PublicCourse.PublicCourseShell
{sections}
{courseTitle}
{org}
activeSlug={itemSlug}
{activeItem}
activeFlatIndex={activeIndex >= 0 ? activeIndex : null}
totalItems={flatItems.length}
hasPrev={!!prevItem}
hasNext={!!nextItem}
{prevItem}
{nextItem}
{hrefFor}
exploreHref="/courses"
signInHref="/login"
exploreLabel={$t('public_course.header.explore_courses')}
signInLabel={$t('public_course.header.sign_in')}
footerPrevLabel={$t('public_course.footer_nav.previous')}
footerNextLabel={$t('public_course.footer_nav.next')}
courseSlug={data.tree.course.slug}
poweredByLabel={$t('public_course.powered_by.label')}
poweredByBrand={$t('public_course.powered_by.brand')}
onItemClick={navigateTo}
onPrev={() => navigateTo(prevItem)}
onNext={() => navigateTo(nextItem)}
>
{#if lessonView}
<PublicCourse.PublicLessonView lesson={lessonView} {callout} />
{:else if exerciseView}
{#key itemSlug}
<PublicCourse.PublicExerciseView
exercise={exerciseView}
{callout}
labels={exerciseLabels}
attemptsPersistenceKey={publicExerciseAttemptsStorageKey(data.tree.course.slug, itemSlug)}
formatAttemptOption={({ attemptNumber, correct, total }) =>
t.get('public_course.exercise.attempt_option', { attemptNumber, correct, total })}
newAttemptOptionLabel={$t('public_course.exercise.practice_again')}
attemptsSelectAriaLabel={$t('public_course.exercise.attempts_select_aria')}
submitLabel={$t('public_course.exercise.submit')}
tryAgainLabel={$t('public_course.exercise.try_again')}
privacyHint={$t('public_course.exercise.privacy_hint')}
summaryTemplate={$t('public_course.exercise.summary_template')}
/>
{/key}
{/if}
</PublicCourse.PublicCourseShell>