feat: enhance API and middleware functionality: add public API routes, implement program and course member checks, and improve rate limiting for invite acceptance. Update OpenAPI spec generation to focus on public API endpoints and add new middleware for automation key scopes. Include new documentation for lesson AI features and public API tests.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { PageRestricted } from '$features/ui';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { globalStore } from '$lib/utils/store/app';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
children?: import('svelte').Snippet;
|
||||
data: {
|
||||
isOrgSite: boolean;
|
||||
orgSiteName: string;
|
||||
org: AccountOrg | null;
|
||||
locals: App.Locals;
|
||||
};
|
||||
}
|
||||
|
||||
let { children, data }: Props = $props();
|
||||
|
||||
onMount(() => {
|
||||
const loadingIndicator = document.getElementById('app-loading-indicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data?.locals?.user) {
|
||||
user.set({
|
||||
...$user,
|
||||
isLoggedIn: true,
|
||||
currentSession: data.locals.user
|
||||
});
|
||||
}
|
||||
|
||||
if (!data.isOrgSite || !data.org) return;
|
||||
|
||||
$globalStore.orgSiteName = data.orgSiteName || '';
|
||||
$globalStore.isOrgSite = data.isOrgSite;
|
||||
|
||||
currentOrg.set(data.org);
|
||||
setTheme(data.org.theme || 'blue');
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.org?.isRestricted || $currentOrg.isRestricted}
|
||||
<PageRestricted />
|
||||
{:else}
|
||||
{@render children?.()}
|
||||
{/if}
|
||||
@@ -0,0 +1,3 @@
|
||||
export const load = async ({ parent }) => {
|
||||
return await parent();
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { MetaTagsProps } from 'svelte-meta-tags';
|
||||
import type { 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';
|
||||
|
||||
type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params = { slug: '' }, parent }) => {
|
||||
const { org: parentOrg } = await parent();
|
||||
|
||||
const courseResult = await safeServerApi<GetCourseBySlugSuccess>(() =>
|
||||
classroomio.course.slug[':slug'].$get(
|
||||
{
|
||||
param: { slug: params.slug }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
);
|
||||
|
||||
if (!courseResult.ok || !courseResult.body.data) {
|
||||
console.error('Failed to fetch course:', courseResult);
|
||||
|
||||
return {
|
||||
slug: params.slug,
|
||||
course: null,
|
||||
org: parentOrg ?? null,
|
||||
pageMetaTags: null
|
||||
};
|
||||
}
|
||||
|
||||
const course = courseResult.body.data;
|
||||
|
||||
let org = parentOrg ?? null;
|
||||
if (!org && course.org?.siteName) {
|
||||
const organizationResult = await safeServerApi<GetOrganizationSuccess>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: { siteName: course.org.siteName }
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
)
|
||||
);
|
||||
|
||||
if (organizationResult.ok) {
|
||||
org = organizationResult.body.data[0] ?? null;
|
||||
} else {
|
||||
console.error('Failed to fetch course organization:', organizationResult);
|
||||
}
|
||||
}
|
||||
|
||||
const pageMetaTags = Object.freeze({
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
openGraph: {
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
images: [
|
||||
{
|
||||
url: course.logo || '',
|
||||
alt: course.title,
|
||||
width: 280,
|
||||
height: 200,
|
||||
secureUrl: course.logo,
|
||||
type: 'image/jpeg'
|
||||
}
|
||||
]
|
||||
},
|
||||
twitter: {
|
||||
handle: '@classroomio',
|
||||
site: '@classroomio',
|
||||
cardType: 'summary_large_image' as const,
|
||||
title: course.title,
|
||||
description: course.description,
|
||||
image: course.logo,
|
||||
imageAlt: 'ClassroomIO OG Image'
|
||||
}
|
||||
}) satisfies MetaTagsProps;
|
||||
|
||||
return {
|
||||
slug: params.slug,
|
||||
course,
|
||||
org,
|
||||
pageMetaTags
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
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 { OrgLandingPageFooter } from '@cio/ui/custom/org-landing-page';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const landingSettings = $derived(normalizeLandingPageSettings(data.org?.landingpage));
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
? {
|
||||
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
|
||||
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
|
||||
}
|
||||
: {
|
||||
label: t.get('navigation.login'),
|
||||
href: '/login'
|
||||
}
|
||||
);
|
||||
|
||||
const shellClass = $derived(
|
||||
landingSettings.theme === 'classic'
|
||||
? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
||||
: 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans'
|
||||
);
|
||||
|
||||
const courseHero = $derived.by(() => {
|
||||
if (!data.course) {
|
||||
return landingSettings.hero;
|
||||
}
|
||||
|
||||
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`, {})
|
||||
},
|
||||
image: data.course.logo || landingSettings.hero.image
|
||||
};
|
||||
});
|
||||
|
||||
const orgName = $derived(data.org?.name ?? data.course?.org?.name ?? 'ClassroomIO');
|
||||
const logoUrl = $derived(data.org?.avatarUrl ?? undefined);
|
||||
|
||||
// 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
|
||||
let HeroComponent = $state<Component<any> | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
const theme = landingSettings.theme;
|
||||
|
||||
const [navMod, heroMod] = await Promise.all([
|
||||
theme === 'bold'
|
||||
? import('@cio/ui/custom/org-landing-page/bold/nav.svelte')
|
||||
: theme === 'classic'
|
||||
? import('@cio/ui/custom/org-landing-page/classic/nav.svelte')
|
||||
: import('@cio/ui/custom/org-landing-page/minimal/nav.svelte'),
|
||||
theme === 'bold'
|
||||
? import('@cio/ui/custom/org-landing-page/bold/hero.svelte')
|
||||
: theme === 'classic'
|
||||
? import('@cio/ui/custom/org-landing-page/classic/hero.svelte')
|
||||
: import('@cio/ui/custom/org-landing-page/minimal/hero.svelte')
|
||||
]);
|
||||
|
||||
NavComponent = navMod.default;
|
||||
HeroComponent = heroMod.default;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if data.course}
|
||||
<PoweredBy />
|
||||
|
||||
<main class={shellClass}>
|
||||
{#if NavComponent && HeroComponent}
|
||||
{#if landingSettings.theme === 'minimal'}
|
||||
<HeroComponent hero={courseHero}>
|
||||
{#snippet navigation()}
|
||||
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
||||
{/snippet}
|
||||
</HeroComponent>
|
||||
{:else}
|
||||
<NavComponent {orgName} {logoUrl} navItems={landingSettings.navItems} {authAction} />
|
||||
<HeroComponent hero={courseHero} />
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<CourseLandingPage courseData={data.course} showStandaloneShell={false} />
|
||||
|
||||
<OrgLandingPageFooter
|
||||
{orgName}
|
||||
{logoUrl}
|
||||
footerLinks={landingSettings.footerLinks}
|
||||
footerText={landingSettings.footerText}
|
||||
variant={landingSettings.theme}
|
||||
/>
|
||||
</main>
|
||||
{/if}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { 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';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
type GetStudentInvitePreviewRequest = (typeof classroomio.invite.student)[':token']['$get'];
|
||||
type GetStudentInvitePreviewSuccess = Extract<InferResponseType<GetStudentInvitePreviewRequest>, { success: true }>;
|
||||
type GetCourseBySlugSuccess = Extract<InferResponseType<GetCourseBySlugRequest>, { success: true }>;
|
||||
|
||||
export const load = async ({ params, url }) => {
|
||||
const slug = params?.slug ?? '';
|
||||
const inviteToken = url.searchParams.get('invite_token') ?? null;
|
||||
|
||||
if (!slug) {
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
if (inviteToken) {
|
||||
const inviteResult = await safeServerApi<GetStudentInvitePreviewSuccess>(() =>
|
||||
classroomio.invite.student[':token'].$get({ param: { token: inviteToken } }, getApiKeyHeaders())
|
||||
);
|
||||
|
||||
if (!inviteResult.ok || !inviteResult.body.data) {
|
||||
console.error('Enroll load: failed to load invite', inviteResult);
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const data = inviteResult.body.data;
|
||||
const course = {
|
||||
id: data.course.id,
|
||||
slug: data.course.slug,
|
||||
title: data.course.title,
|
||||
description: data.course.description,
|
||||
cost: data.course.cost ?? 0,
|
||||
allowNewStudent: data.course.allowNewStudent ?? true,
|
||||
status: data.course.status,
|
||||
isPublished: data.course.isPublished
|
||||
};
|
||||
const currentOrg = {
|
||||
id: data.organization.id,
|
||||
name: data.organization.name,
|
||||
siteName: data.organization.siteName ?? '',
|
||||
theme: data.organization.theme ?? undefined
|
||||
};
|
||||
|
||||
return {
|
||||
course,
|
||||
currentOrg,
|
||||
invite: data.invite,
|
||||
inviteEmail: data.inviteContext?.recipientEmail ?? null,
|
||||
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
|
||||
requiresPaymentOrInvite: false,
|
||||
token: inviteToken
|
||||
};
|
||||
}
|
||||
|
||||
const courseResult = await safeServerApi<GetCourseBySlugSuccess>(() =>
|
||||
classroomio.course.slug[':slug'].$get({
|
||||
param: { slug }
|
||||
})
|
||||
);
|
||||
|
||||
if (!courseResult.ok || !courseResult.body.data) {
|
||||
console.error('Enroll load: failed to load course by slug', courseResult);
|
||||
throw error(404, 'Not found');
|
||||
}
|
||||
|
||||
const courseData = courseResult.body.data;
|
||||
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);
|
||||
const isFree = calculatedCost <= 0;
|
||||
|
||||
const currentOrg = courseData.org
|
||||
? {
|
||||
id: courseData.org.id,
|
||||
name: courseData.org.name,
|
||||
siteName: courseData.org.siteName ?? '',
|
||||
theme: courseData.org.theme ?? undefined
|
||||
}
|
||||
: null;
|
||||
|
||||
const course = {
|
||||
id: courseData.id,
|
||||
slug: courseData.slug,
|
||||
title: courseData.title,
|
||||
description: courseData.description,
|
||||
cost: courseData.cost ?? 0,
|
||||
allowNewStudent: (courseData.metadata as { allowNewStudent?: boolean } | null)?.allowNewStudent ?? true,
|
||||
status: courseData.status,
|
||||
isPublished: courseData.isPublished
|
||||
};
|
||||
|
||||
return {
|
||||
course,
|
||||
currentOrg,
|
||||
invite: null,
|
||||
requiresPaymentOrInvite: !isFree,
|
||||
token: null as string | null
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { AuthUI } from '$features/ui';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { setTheme } from '$lib/utils/functions/theme';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { capturePosthogEvent } from '$lib/utils/services/posthog';
|
||||
import { resolve } from '$app/paths';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let loading = $state(false);
|
||||
|
||||
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
|
||||
const canJoinCourse = $derived(
|
||||
data.requiresPaymentOrInvite
|
||||
? false
|
||||
: (inviteStatus === 'ACTIVE' || !data.invite) &&
|
||||
data.course?.allowNewStudent !== false &&
|
||||
data.course?.status === 'ACTIVE' &&
|
||||
Boolean(data.course?.isPublished)
|
||||
);
|
||||
|
||||
function getBlockedMessage(): string {
|
||||
if (data.requiresPaymentOrInvite) {
|
||||
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
|
||||
}
|
||||
if (data.course?.allowNewStudent === false) {
|
||||
return t.get('course.navItem.landing_page.pricing_section.not_accepting');
|
||||
}
|
||||
if (data.course?.status !== 'ACTIVE' || !data.course?.isPublished) {
|
||||
return 'This course is currently unavailable for enrollment.';
|
||||
}
|
||||
if (data.invite && inviteStatus === 'EXPIRED') {
|
||||
return 'This invite link has expired.';
|
||||
}
|
||||
if (data.invite && inviteStatus === 'USED_UP') {
|
||||
return 'This invite link has reached its usage limit.';
|
||||
}
|
||||
if (data.invite && inviteStatus === 'REVOKED') {
|
||||
return 'This invite link has been revoked.';
|
||||
}
|
||||
if (data.invite && inviteStatus !== 'ACTIVE') {
|
||||
return 'This invite link is not valid.';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canJoinCourse) {
|
||||
snackbar.error(getBlockedMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
const redirectPath = page.url?.pathname ?? `/course/${data.course?.slug ?? ''}/enroll`;
|
||||
const redirectSearch = data.token ? `?invite_token=${encodeURIComponent(data.token)}` : '';
|
||||
const redirectUrl = `${redirectPath}${redirectSearch}`;
|
||||
|
||||
if (!$profile.id || !$profile.email) {
|
||||
const inviteEmail = data.inviteEmail ?? '';
|
||||
const target = data.inviteEmailExists ? '/login' : '/signup';
|
||||
const params = new URLSearchParams({ redirect: redirectUrl });
|
||||
if (inviteEmail) params.set('email', inviteEmail);
|
||||
|
||||
goto(resolve(`${target}?${params.toString()}`, {}));
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = data.token ? { inviteToken: data.token } : {};
|
||||
const result = await courseApi.enroll(data.course!.id, body);
|
||||
|
||||
if (!result?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
capturePosthogEvent('student_joined_course', {
|
||||
course_name: data.course?.title,
|
||||
student_id: $profile.id,
|
||||
student_email: $profile.email,
|
||||
already_joined: result.data.alreadyJoined
|
||||
});
|
||||
|
||||
// need to force page reload to avoid cache issues
|
||||
window.location.href = result.data.redirectTo || '/lms';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!data.currentOrg) return;
|
||||
setTheme(data.currentOrg?.theme);
|
||||
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
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Join {data.course?.title ?? 'Course'} on ClassroomIO</title>
|
||||
</svelte:head>
|
||||
|
||||
<AuthUI isLogin={false} {handleSubmit} isLoading={loading || !$profile.id} showOnlyContent={true} showLogo={true}>
|
||||
<div class="mt-0 w-full">
|
||||
<h3 class="mt-0 mb-4 text-center text-lg font-medium dark:text-white">{data.course?.title}</h3>
|
||||
<p class="text-center text-sm font-light dark:text-white">{data.course?.description}</p>
|
||||
{#if data.requiresPaymentOrInvite}
|
||||
<p class="ui:text-muted-foreground mt-3 text-center text-sm">
|
||||
{$t('course.navItem.landing_page.enroll_page.requires_payment_or_invite')}
|
||||
</p>
|
||||
<a
|
||||
href={resolve(`/course/${data.course?.slug ?? ''}`, {})}
|
||||
class="ui:text-primary ui:underline mt-3 block text-center text-sm"
|
||||
>
|
||||
{$t('course.navItem.landing_page.enroll_page.back_to_course')}
|
||||
</a>
|
||||
{:else if !canJoinCourse}
|
||||
<p class="mt-3 text-center text-sm text-red-500">{getBlockedMessage()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="my-4 flex w-full items-center justify-center">
|
||||
<Button type="submit" disabled={!canJoinCourse || loading} loading={loading || !$profile.id}>
|
||||
{$t('course.navItem.landing_page.enroll_page.join_course')}
|
||||
</Button>
|
||||
</div>
|
||||
</AuthUI>
|
||||
@@ -0,0 +1,60 @@
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
type GetPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||
type GetPublicCoursesSuccess = Extract<InferResponseType<GetPublicCoursesRequest>, { success: true }>;
|
||||
type GetPublicTagsRequest = typeof classroomio.organization.tags.public.$get;
|
||||
type GetPublicTagsSuccess = Extract<InferResponseType<GetPublicTagsRequest>, { success: true }>;
|
||||
|
||||
function normalizeTagsParam(rawTags: string | null): string[] {
|
||||
const normalized = rawTags
|
||||
?.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return normalized ?? [];
|
||||
}
|
||||
|
||||
export const load = async ({ parent, url }) => {
|
||||
const { isOrgSite, orgSiteName, org } = await parent();
|
||||
|
||||
if (!isOrgSite || !org) {
|
||||
throw redirect(307, '/');
|
||||
}
|
||||
|
||||
const siteName = orgSiteName || org.siteName;
|
||||
if (!siteName) {
|
||||
throw redirect(307, '/');
|
||||
}
|
||||
|
||||
const normalizedTags = normalizeTagsParam(url.searchParams.get('tags'));
|
||||
const normalizedTagsQuery = normalizedTags.length > 0 ? normalizedTags.join(',') : undefined;
|
||||
|
||||
const [coursesResult, tagsResult] = await Promise.all([
|
||||
safeServerApi<GetPublicCoursesSuccess>(() =>
|
||||
classroomio.organization.courses.public.$get({
|
||||
query: {
|
||||
siteName,
|
||||
...(normalizedTagsQuery ? { tags: normalizedTagsQuery } : {})
|
||||
}
|
||||
})
|
||||
),
|
||||
safeServerApi<GetPublicTagsSuccess>(() =>
|
||||
classroomio.organization.tags.public.$get({
|
||||
query: {
|
||||
siteName
|
||||
}
|
||||
})
|
||||
)
|
||||
]);
|
||||
const courseData = coursesResult.ok ? coursesResult.body.data : { courses: [], hasMoreCourses: false };
|
||||
|
||||
return {
|
||||
org,
|
||||
courses: courseData.courses,
|
||||
hasMoreCourses: courseData.hasMoreCourses,
|
||||
tagGroups: tagsResult.ok ? tagsResult.body.data : [],
|
||||
activeTags: normalizedTags
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { SvelteSet, SvelteURLSearchParams } from 'svelte/reactivity';
|
||||
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
|
||||
import { PoweredBy } from '$features/ui';
|
||||
import LibraryBigIcon from '@lucide/svelte/icons/library-big';
|
||||
import { CourseCardList } from '$features/course/components';
|
||||
import { normalizeLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
|
||||
import {
|
||||
BoldLandingHero,
|
||||
BoldLandingNav,
|
||||
ClassicLandingHero,
|
||||
ClassicLandingNav,
|
||||
MinimalLandingHero,
|
||||
MinimalLandingNav,
|
||||
OrgLandingPageFooter
|
||||
} from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Checkbox } from '@cio/ui/base/checkbox';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { Separator } from '@cio/ui/base/separator';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let selectedTags = $derived<string[]>(data.activeTags || []);
|
||||
|
||||
const landingSettings = $derived(normalizeLandingPageSettings(data.org.landingpage));
|
||||
|
||||
const authAction = $derived(
|
||||
$user.isLoggedIn
|
||||
? {
|
||||
label: t.get($basePath === '/lms' || $basePath === '#' ? 'navigation.goto_lms' : 'navigation.goto_dashboard'),
|
||||
href: resolve($basePath !== '#' ? $basePath : '/lms', {})
|
||||
}
|
||||
: {
|
||||
label: t.get('navigation.login'),
|
||||
href: '/login'
|
||||
}
|
||||
);
|
||||
|
||||
const shellClass = $derived(
|
||||
landingSettings.theme === 'classic'
|
||||
? 'ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans'
|
||||
: 'ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans'
|
||||
);
|
||||
|
||||
async function applyTagFilters(nextTags: string[]) {
|
||||
const params = new SvelteURLSearchParams(page.url.searchParams);
|
||||
|
||||
if (nextTags.length > 0) {
|
||||
params.set('tags', nextTags.join(','));
|
||||
} else {
|
||||
params.delete('tags');
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const suffix = query ? `?${query}` : '';
|
||||
|
||||
await goto(resolve(`/courses${suffix}`, {}), {
|
||||
keepFocus: true,
|
||||
noScroll: true,
|
||||
invalidateAll: true
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTag(tagSlug: string, checked: boolean) {
|
||||
const next = new SvelteSet(selectedTags);
|
||||
if (checked) {
|
||||
next.add(tagSlug);
|
||||
} else {
|
||||
next.delete(tagSlug);
|
||||
}
|
||||
|
||||
applyTagFilters(Array.from(next));
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
if (selectedTags.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
applyTagFilters([]);
|
||||
}
|
||||
|
||||
function isTagSelected(tagSlug: string) {
|
||||
return selectedTags.includes(tagSlug);
|
||||
}
|
||||
|
||||
const displayTagGroups = $derived.by(() => {
|
||||
const sourceGroups = data.tagGroups ?? [];
|
||||
const tagsByGroupId: Record<string, (typeof sourceGroups)[number]['tags']> = {};
|
||||
|
||||
for (const group of sourceGroups) {
|
||||
for (const tag of group.tags ?? []) {
|
||||
const groupId = tag.groupId || group.id;
|
||||
const existing = tagsByGroupId[groupId] ?? [];
|
||||
|
||||
if (!existing.some((item) => item.id === tag.id)) {
|
||||
existing.push(tag);
|
||||
tagsByGroupId[groupId] = existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sourceGroups.map((group) => ({
|
||||
...group,
|
||||
tags: tagsByGroupId[group.id] ?? []
|
||||
}));
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t('public_courses.page_title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<PoweredBy />
|
||||
|
||||
<main class={shellClass}>
|
||||
{#if landingSettings.theme === 'minimal'}
|
||||
<MinimalLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }}>
|
||||
{#snippet navigation()}
|
||||
<MinimalLandingNav
|
||||
orgName={data.org.name}
|
||||
logoUrl={data.org.avatarUrl ?? undefined}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
{/snippet}
|
||||
</MinimalLandingHero>
|
||||
{:else if landingSettings.theme === 'bold'}
|
||||
<BoldLandingNav
|
||||
orgName={data.org.name}
|
||||
logoUrl={data.org.avatarUrl ?? undefined}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
<BoldLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }} />
|
||||
{:else}
|
||||
<ClassicLandingNav
|
||||
orgName={data.org.name}
|
||||
logoUrl={data.org.avatarUrl ?? undefined}
|
||||
navItems={landingSettings.navItems}
|
||||
{authAction}
|
||||
/>
|
||||
<ClassicLandingHero hero={{ ...landingSettings.hero, heading: $t('public_courses.heading') }} />
|
||||
{/if}
|
||||
|
||||
<section class="mx-auto w-full max-w-7xl px-4 py-8 md:px-6">
|
||||
<div class="mb-6 flex items-center justify-end gap-4">
|
||||
<Button variant="outline" onclick={clearFilters} disabled={selectedTags.length === 0}>
|
||||
{$t('public_courses.clear_filters')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-8 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside class="ui:bg-background space-y-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">{$t('public_courses.filters.title')}</h2>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('public_courses.filters.help')}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{#if displayTagGroups.length === 0}
|
||||
<p class="ui:text-muted-foreground text-sm">{$t('public_courses.filters.empty')}</p>
|
||||
{:else}
|
||||
<div class="space-y-5">
|
||||
{#each displayTagGroups as group (group.id)}
|
||||
<div class="space-y-2">
|
||||
<div class="space-y-1">
|
||||
<h3 class="text-sm font-semibold">{group.name}</h3>
|
||||
{#if group.description}
|
||||
<p class="ui:text-muted-foreground text-xs">{group.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
{#each group.tags as tag (tag.id)}
|
||||
<label
|
||||
class="hover:ui:bg-muted/30 flex cursor-pointer items-center justify-between rounded-md border px-3 py-2"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={isTagSelected(tag.slug)}
|
||||
onCheckedChange={(checked) => toggleTag(tag.slug, Boolean(checked))}
|
||||
/>
|
||||
<span
|
||||
class="inline-block h-2.5 w-2.5 rounded-full border"
|
||||
style={`background-color: ${tag.color}`}
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
<span class="text-sm">{tag.name}</span>
|
||||
</div>
|
||||
<span class="ui:text-muted-foreground text-xs">{tag.courseCount}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<div class="space-y-4">
|
||||
{#if data.courses.length === 0}
|
||||
<Empty
|
||||
icon={LibraryBigIcon}
|
||||
title={$t('public_courses.empty.title')}
|
||||
description={$t('public_courses.empty.description')}
|
||||
variant="page"
|
||||
/>
|
||||
{:else}
|
||||
<CourseCardList courses={data.courses} isOnLandingPage={true} />
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OrgLandingPageFooter
|
||||
orgName={data.org.name}
|
||||
logoUrl={data.org.avatarUrl ?? undefined}
|
||||
footerLinks={landingSettings.footerLinks}
|
||||
footerText={landingSettings.footerText}
|
||||
variant={landingSettings.theme}
|
||||
/>
|
||||
</main>
|
||||
Reference in New Issue
Block a user