chore: ai, public course and other ui improvements
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user