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
@@ -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>