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:
@@ -7,9 +7,9 @@ import type { DashStatsSuccess } from '$features/org/utils/types';
|
||||
*/
|
||||
class DashStatApi extends BaseApi {
|
||||
stats = $state<DashStatsSuccess['data']>({
|
||||
enrollments: [],
|
||||
recentCertifications: [],
|
||||
numberOfCourses: 0,
|
||||
revenue: 0,
|
||||
totalCertificates: 0,
|
||||
totalStudents: 0,
|
||||
topCourses: []
|
||||
});
|
||||
@@ -18,7 +18,7 @@ class DashStatApi extends BaseApi {
|
||||
* Fetches dashboard statistics for an organization
|
||||
* @param orgId Optional organization ID
|
||||
* @param siteName Optional site name
|
||||
* @returns Organization analytics data (enrollments, courses, revenue, students, top courses)
|
||||
* @returns Organization analytics data (certifications, courses, students, top courses)
|
||||
*/
|
||||
async fetchOrgStats({ orgId, siteName }: { orgId?: string; siteName?: string }) {
|
||||
await this.execute<typeof classroomio.dash.stats.$get>({
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { TCancelOrgPlan, TCreateOrgPlan, TUpdateOrgPlan } from '@cio/utils/validation/organization';
|
||||
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders } from '$lib/utils/services/api/server';
|
||||
import { classroomio, type InferResponseType } from '$lib/utils/services/api';
|
||||
import { getApiKeyHeaders, safeServerApi } from '$lib/utils/services/api/server';
|
||||
|
||||
type CreateOrgPlanRequest = typeof classroomio.organization.plan.$post;
|
||||
type CreateOrgPlanSuccess = Extract<InferResponseType<CreateOrgPlanRequest>, { success: true }>;
|
||||
type UpdateOrgPlanRequest = typeof classroomio.organization.plan.$put;
|
||||
type UpdateOrgPlanSuccess = Extract<InferResponseType<UpdateOrgPlanRequest>, { success: true }>;
|
||||
type CancelOrgPlanRequest = typeof classroomio.organization.plan.cancel.$post;
|
||||
type CancelOrgPlanSuccess = Extract<InferResponseType<CancelOrgPlanRequest>, { success: true }>;
|
||||
|
||||
/**
|
||||
* Server-side API methods for organization plan operations
|
||||
@@ -15,20 +22,21 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async createOrgPlan(params: TCreateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$post(
|
||||
const result = await safeServerApi<CreateOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error creating org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error creating org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -37,20 +45,21 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async updateOrgPlan(params: TUpdateOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.$put(
|
||||
const result = await safeServerApi<UpdateOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.$put(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error updating org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error updating org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,19 +68,20 @@ export class OrgPlanApiServer {
|
||||
* @returns Response data or null on error
|
||||
*/
|
||||
static async cancelOrgPlan(params: TCancelOrgPlan) {
|
||||
try {
|
||||
const response = await classroomio.organization.plan.cancel.$post(
|
||||
const result = await safeServerApi<CancelOrgPlanSuccess>(() =>
|
||||
classroomio.organization.plan.cancel.$post(
|
||||
{
|
||||
json: params
|
||||
},
|
||||
getApiKeyHeaders()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success ? data.data : null;
|
||||
} catch (error) {
|
||||
console.error('Error canceling org plan (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error canceling org plan (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { classroomio } from '$lib/utils/services/api';
|
||||
import { safeServerApi } from '$lib/utils/services/api/server';
|
||||
import type { FirstOrganizationRecord, OrganizationRecord } from '../utils/types';
|
||||
|
||||
/**
|
||||
* Server-side organization API functions.
|
||||
@@ -15,14 +17,16 @@ import { classroomio } from '$lib/utils/services/api';
|
||||
* @returns First organization or null
|
||||
*/
|
||||
export async function getFirstOrg(apiKeyHeaders: { headers: Record<string, string> }) {
|
||||
try {
|
||||
const response = await classroomio.organization.first.$get(undefined, apiKeyHeaders);
|
||||
const data = await response.json();
|
||||
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching first organization (server):', error);
|
||||
const result = await safeServerApi<{ success: true; data: FirstOrganizationRecord[] }>(() =>
|
||||
classroomio.organization.first.$get(undefined, apiKeyHeaders)
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching first organization (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data.length > 0 ? result.body.data[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,20 +36,21 @@ export async function getFirstOrg(apiKeyHeaders: { headers: Record<string, strin
|
||||
* @returns Organization or null
|
||||
*/
|
||||
export async function getOrgBySiteName(siteName: string, apiKeyHeaders: { headers: Record<string, string> }) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
const result = await safeServerApi<{ success: true; data: OrganizationRecord[] }>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: { siteName }
|
||||
},
|
||||
apiKeyHeaders
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data && data.data.length > 0 ? data.data[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Error fetching organization by siteName (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching organization by siteName (server):', result);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.body.data.length > 0 ? result.body.data[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,8 +65,8 @@ export async function getOrgsByCustomDomain(
|
||||
isCustomDomainVerified: boolean,
|
||||
apiKeyHeaders: { headers: Record<string, string> }
|
||||
) {
|
||||
try {
|
||||
const response = await classroomio.organization.$get(
|
||||
const result = await safeServerApi<{ success: true; data: OrganizationRecord[] }>(() =>
|
||||
classroomio.organization.$get(
|
||||
{
|
||||
query: {
|
||||
customDomain,
|
||||
@@ -69,12 +74,13 @@ export async function getOrgsByCustomDomain(
|
||||
}
|
||||
},
|
||||
apiKeyHeaders
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
return data.success && data.data ? data.data : [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching organizations by custom domain (server):', error);
|
||||
if (!result.ok) {
|
||||
console.error('Error fetching organizations by custom domain (server):', result);
|
||||
return [];
|
||||
}
|
||||
|
||||
return result.body.data;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ class OrgApi extends BaseApiWithErrors {
|
||||
audience = $state<OrganizationAudience>([]);
|
||||
audiencePagination = $state<OrganizationAudiencePagination | null>(null);
|
||||
publicCourses: OrgPublicCourses = $state([]);
|
||||
hasMorePublicCourses = $state(false);
|
||||
|
||||
isFetchingOrgPublicCourses = $state(false);
|
||||
private activeAudienceRequestController: AbortController | null = null;
|
||||
@@ -165,7 +166,8 @@ class OrgApi extends BaseApiWithErrors {
|
||||
}),
|
||||
logContext: 'fetching public courses',
|
||||
onSuccess: (response) => {
|
||||
this.publicCourses = response.data;
|
||||
this.publicCourses = response.data.courses;
|
||||
this.hasMorePublicCourses = response.data.hasMoreCourses;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,36 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { fade } from 'svelte/transition';
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import PhoneIcon from '@lucide/svelte/icons/phone';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import MapPinIcon from '@lucide/svelte/icons/map-pin';
|
||||
import TwitterIcon from '@lucide/svelte/icons/twitter';
|
||||
import FacebookIcon from '@lucide/svelte/icons/facebook';
|
||||
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
|
||||
import { preventDefault } from '$lib/utils/functions/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
import Navigation from '$lib/components/Navigation/index.svelte';
|
||||
import { PoweredBy } from '$features/ui';
|
||||
import { CourseCardList, CourseCardLoader } from '$features/course/components';
|
||||
import { landingPageSettings } from '$features/org/components/settings/landing-page-store';
|
||||
import { CoursesEmptyIcon } from '$features/ui/icons';
|
||||
|
||||
import PageLoader from './page-loader.svelte';
|
||||
import * as Accordion from '@cio/ui/base/accordion';
|
||||
import * as Button from '@cio/ui/base/button';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { Input } from '@cio/ui/base/input';
|
||||
import { Textarea } from '@cio/ui/base/textarea';
|
||||
import { Empty } from '@cio/ui/custom/empty';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { validateEmail } from '$lib/utils/functions/validateEmail';
|
||||
import { orgLandingpageValidation } from '$lib/utils/functions/validator';
|
||||
import { MediaPlayer, isYoutubeUrl } from '@cio/ui/custom/media-player';
|
||||
import PageLoader from './page-loader.svelte';
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import {
|
||||
buildOrgLandingPageProps,
|
||||
importThemeComponent,
|
||||
normalizeLandingPageSettings
|
||||
} from '$features/org/utils/landing-page';
|
||||
import { basePath } from '$lib/utils/store/app';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { user } from '$lib/utils/store/user';
|
||||
|
||||
interface Props {
|
||||
orgSiteName?: string;
|
||||
@@ -39,519 +22,54 @@
|
||||
|
||||
let { orgSiteName = '', org }: Props = $props();
|
||||
|
||||
let email: string | undefined = $state('');
|
||||
let isAdding = $state(false);
|
||||
let success = $state(false);
|
||||
let successContactSaved = $state(false);
|
||||
let isContactSubmiting = $state(false);
|
||||
let contact = $state({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
});
|
||||
let contactError: Record<string, string> = $state({});
|
||||
let hasLoadedCourses = $state(false);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let ThemeComponent = $state<Component<any> | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!email || !validateEmail(email) || !org) return;
|
||||
isAdding = true;
|
||||
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'
|
||||
}
|
||||
);
|
||||
|
||||
// TODO: Implement email list submission
|
||||
const landingPageProps = $derived(
|
||||
buildOrgLandingPageProps(
|
||||
org,
|
||||
normalizeLandingPageSettings(org.landingpage),
|
||||
orgApi.publicCourses,
|
||||
orgApi.hasMorePublicCourses,
|
||||
authAction
|
||||
)
|
||||
);
|
||||
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
isAdding = false;
|
||||
success = false;
|
||||
email = undefined;
|
||||
}, 5000);
|
||||
}
|
||||
onMount(async () => {
|
||||
const settings = normalizeLandingPageSettings(org.landingpage);
|
||||
const mod = await importThemeComponent(settings.theme);
|
||||
ThemeComponent = mod.default;
|
||||
|
||||
async function handleContactSubmit() {
|
||||
isContactSubmiting = true;
|
||||
contactError = orgLandingpageValidation(contact);
|
||||
if (Object.keys(contactError).length) {
|
||||
isContactSubmiting = false;
|
||||
const siteName = orgSiteName || org.siteName;
|
||||
if (!siteName) {
|
||||
hasLoadedCourses = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to db
|
||||
// TODO: Implement contact submission
|
||||
|
||||
isContactSubmiting = false;
|
||||
successContactSaved = true;
|
||||
contact = {
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
message: ''
|
||||
};
|
||||
}
|
||||
$effect(() => {
|
||||
if (!orgSiteName) return;
|
||||
|
||||
orgApi.getPublicCoursesBySiteName(orgSiteName);
|
||||
});
|
||||
|
||||
// Use default data in store if user hasn't added their own content to landing page
|
||||
function setDefault(landingpage) {
|
||||
if (landingpage && Object.keys(landingpage).length) {
|
||||
if (!landingpage?.header?.banner) {
|
||||
landingpage.header.banner = $landingPageSettings.header.banner;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
$landingPageSettings = {
|
||||
...landingpage
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getBgImage(settings) {
|
||||
const defaultImg = '/images/org-landingpage-banner.jpeg';
|
||||
|
||||
const { show, image } = settings.header.background || {
|
||||
show: true,
|
||||
image: defaultImg
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return `url('${image || defaultImg}')`;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
setDefault(org?.landingpage);
|
||||
orgApi.publicCourses = [];
|
||||
orgApi.hasMorePublicCourses = false;
|
||||
hasLoadedCourses = false;
|
||||
void orgApi.getPublicCoursesBySiteName(siteName).finally(() => {
|
||||
hasLoadedCourses = true;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{!org?.name ? '' : `${org.name}'s `}{$t('course.navItem.landing_page.landing_page')}
|
||||
</title>
|
||||
{#if $landingPageSettings.header.background?.show !== false}
|
||||
<link
|
||||
rel="preload"
|
||||
as="image"
|
||||
href={$landingPageSettings.header.background?.image || '/images/org-landingpage-banner.jpeg'}
|
||||
/>
|
||||
{/if}
|
||||
</svelte:head>
|
||||
|
||||
<PoweredBy />
|
||||
|
||||
{#if !org?.landingpage}
|
||||
{#if !ThemeComponent || !hasLoadedCourses || orgApi.isFetchingOrgPublicCourses}
|
||||
<PageLoader />
|
||||
{:else}
|
||||
<main>
|
||||
<Navigation
|
||||
logo={org.avatarUrl || '/logo-192.png'}
|
||||
orgName={org.name}
|
||||
disableSignup={org.disableSignup || (org.settings?.signup?.inviteOnly ?? false)}
|
||||
isOrgSite={true}
|
||||
customLinks={$landingPageSettings.customLinks}
|
||||
/>
|
||||
<!-- Header Section -->
|
||||
{#if $landingPageSettings.header.show}
|
||||
<header
|
||||
id="header"
|
||||
class={`relative mb-10 h-screen w-full md:h-[90vh] ${
|
||||
$landingPageSettings.header.background?.show ? 'bg-cover bg-center' : 'border-b border-gray-300'
|
||||
}`}
|
||||
style="background-image: {getBgImage($landingPageSettings)}"
|
||||
>
|
||||
<div class="absolute top-0 z-10 h-screen w-full bg-white opacity-80 md:h-[90vh]"></div>
|
||||
{#if $landingPageSettings.header.banner.show}
|
||||
<div class="flex items-center justify-center py-2 md:h-full">
|
||||
<div
|
||||
class="relative z-20 flex w-full flex-col-reverse items-center justify-center md:w-11/12 md:flex-row md:justify-between lg:w-5/6"
|
||||
>
|
||||
<!-- Org Description -->
|
||||
<div class="mr-3 flex w-11/12 flex-col items-center py-10 md:w-2/5 md:items-start">
|
||||
<p class="ui:text-primary text-2xl font-semibold capitalize">
|
||||
{org.name}
|
||||
</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-start md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-3 text-center text-xl md:mb-5 md:text-start">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-2 w-fit px-10 md:mt-5"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
|
||||
<div class="flex w-5/6 md:w-1/2 md:max-w-[650px]">
|
||||
{#if $landingPageSettings.header?.banner?.video && $landingPageSettings.header.banner.type === 'video'}
|
||||
<div style="width:100%; border-radius:12px">
|
||||
<MediaPlayer
|
||||
source={{
|
||||
type: isYoutubeUrl($landingPageSettings.header.banner.video) ? 'youtube' : 'generic',
|
||||
url: $landingPageSettings.header.banner.video
|
||||
}}
|
||||
options={{
|
||||
width: '100%',
|
||||
height: '400'
|
||||
}}
|
||||
class="rounded-xl"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<img
|
||||
class="rounded-md"
|
||||
src={$landingPageSettings.header?.banner?.image}
|
||||
alt="landing page banner"
|
||||
width="640"
|
||||
height="427"
|
||||
fetchpriority="high"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="relative z-20 flex h-full w-full items-center justify-center md:flex-row">
|
||||
<div class="mx-auto flex w-11/12 max-w-[600px] flex-col items-center py-10">
|
||||
<p class=" ui:text-primary text-2xl font-semibold capitalize">{org.name}</p>
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.header.title} <br /><span class="ui:text-primary"
|
||||
>{$landingPageSettings.header.titleHighlight}</span
|
||||
>
|
||||
</h1>
|
||||
<p class="text-md mb-6 text-center text-xl">
|
||||
{$landingPageSettings.header.subtitle}
|
||||
</p>
|
||||
|
||||
<Button.Root
|
||||
class="mt-5 w-fit px-10"
|
||||
onclick={() => {
|
||||
$landingPageSettings.header.action.redirect && goto($landingPageSettings.header.action.link);
|
||||
}}
|
||||
>
|
||||
{$landingPageSettings.header.action.label}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</header>
|
||||
{/if}
|
||||
|
||||
<!-- Our Story section -->
|
||||
{#if $landingPageSettings.aboutUs.show}
|
||||
<section id="aboutus" class="m-h-[400px] mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="mx-4 flex flex-col items-center justify-evenly lg:flex-row">
|
||||
<div class="mr-5 mb-5 max-w-[600px] lg:mb-0 lg:w-2/5">
|
||||
<h2 class="text-4xl md:text-5xl lg:text-6xl">{$landingPageSettings.aboutUs.title}</h2>
|
||||
<p class="mb-2">
|
||||
{$landingPageSettings.aboutUs.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="image">
|
||||
<img
|
||||
src={$landingPageSettings.aboutUs.imageUrl}
|
||||
alt="Our Story"
|
||||
class="max-h-[450px] rounded-2xl"
|
||||
width="900"
|
||||
height="562"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Courses Section -->
|
||||
{#if $landingPageSettings.courses.show}
|
||||
<section id="courses" transition:fade class="mx-auto my-10 w-full max-w-6xl">
|
||||
<div class="w-full">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.courses.title}
|
||||
<span class="text-primary">{$landingPageSettings.courses.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">
|
||||
{$landingPageSettings.courses.subtitle}
|
||||
</p>
|
||||
<div class="mt-5 flex justify-center">
|
||||
<Button.Root variant="link" class="w-fit px-8 py-4" onclick={() => goto(resolve('/courses', {}))}>
|
||||
{$t('course.navItem.landing_page.view_all')}
|
||||
</Button.Root>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if orgApi.isFetchingOrgPublicCourses}
|
||||
<div class="cards-container mx-2 my-4">
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
<CourseCardLoader />
|
||||
</div>
|
||||
{:else if orgApi.publicCourses.length > 0}
|
||||
<CourseCardList courses={orgApi.publicCourses.slice(0, 3)} isOnLandingPage={true} />
|
||||
{:else}
|
||||
<Empty
|
||||
icon={CoursesEmptyIcon}
|
||||
title={$t('course.navItem.landing_page.no_course_published')}
|
||||
description={$t('course.navItem.landing_page.coming_your_way')}
|
||||
variant="page"
|
||||
/>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- FAQ Section -->
|
||||
{#if $landingPageSettings.faq.show}
|
||||
<section id="faq" transition:fade class="mx-auto my-10 w-full max-w-[700px]">
|
||||
<div class="py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.faq.title}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="mx-2">
|
||||
<Accordion.Root type="single" class="w-full">
|
||||
{#each $landingPageSettings.faq.questions as faq, index}
|
||||
<Accordion.Item value="item-{index}">
|
||||
<Accordion.Trigger>
|
||||
{faq.title}
|
||||
</Accordion.Trigger>
|
||||
<Accordion.Content>
|
||||
{faq.content}
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
{/each}
|
||||
</Accordion.Root>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Contact Section-->
|
||||
{#if $landingPageSettings.contact.show}
|
||||
<section id="contact" transition:fade class="ui:bg-primary-foreground my-10 w-full">
|
||||
<div class="mx-auto w-full max-w-6xl">
|
||||
<div class="mx-auto w-11/12 max-w-[500px] py-10">
|
||||
<h1 class="my-4 text-center text-4xl md:text-5xl lg:text-6xl">
|
||||
{$landingPageSettings.contact.title}
|
||||
<span class="ui:text-primary">{$landingPageSettings.contact.titleHighlight}</span>
|
||||
</h1>
|
||||
<p class="text-md text-center">{$landingPageSettings.contact.subtitle}</p>
|
||||
</div>
|
||||
<div class="mx-auto max-w-[700px] pb-10">
|
||||
<!-- Contact Details -->
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<MapPinIcon size={16} class="filled" />
|
||||
<p class="mt-3 max-w-[200px] text-xs md:text-sm">
|
||||
{$landingPageSettings.contact.address}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<PhoneIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.phone}</p>
|
||||
</div>
|
||||
<div
|
||||
class="mx-2 flex cursor-pointer flex-col items-center justify-center rounded-lg border py-2 text-center break-all transition-all duration-500"
|
||||
>
|
||||
<MailIcon size={16} />
|
||||
<p class="mt-3 text-xs md:text-sm">{$landingPageSettings.contact.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="mt-8 rounded-lg bg-white p-7">
|
||||
{#if successContactSaved}
|
||||
<div class="flex w-full items-center justify-center">
|
||||
{$t('course.navItem.landing_page.thank_you')}
|
||||
</div>
|
||||
{:else}
|
||||
<form onsubmit={preventDefault(handleContactSubmit)}>
|
||||
<div class="flex w-full flex-col justify-between md:flex-row">
|
||||
<div class="mr-5 w-full md:w-2/4">
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="font-bold">
|
||||
{$t('course.navItem.landing_page.name')}
|
||||
</Field.Label>
|
||||
<Input bind:value={contact.name} placeholder="Elon Musk" aria-invalid={!!contactError.name} />
|
||||
{#if contactError.name}
|
||||
<Field.Error>{contactError.name}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.email')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.email}
|
||||
type="email"
|
||||
placeholder="[email protected]"
|
||||
aria-invalid={!!contactError.email}
|
||||
/>
|
||||
{#if contactError.email}
|
||||
<Field.Error>{contactError.email}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
<Field.Field class="mb-5">
|
||||
<Field.Label class="text-xs font-normal">
|
||||
{$t('course.navItem.landing_page.phone')}
|
||||
</Field.Label>
|
||||
<Input
|
||||
bind:value={contact.phone}
|
||||
placeholder="+1194802480"
|
||||
aria-invalid={!!contactError.phone}
|
||||
/>
|
||||
{#if contactError.phone}
|
||||
<Field.Error>{contactError.phone}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="w-full md:w-2/4">
|
||||
<Field.Field>
|
||||
<Field.Label>
|
||||
{$t('course.navItem.landing_page.message')}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
bind:value={contact.message}
|
||||
rows={9}
|
||||
placeholder={$t('course.navItem.landing_page.your_message')}
|
||||
aria-invalid={!!contactError.message}
|
||||
/>
|
||||
{#if contactError.message}
|
||||
<Field.Error>{contactError.message}</Field.Error>
|
||||
{/if}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button.Root class="mx-auto mt-5 w-full md:mt-0" type="submit" loading={isContactSubmiting}>
|
||||
<span class="text-md mr-2">{$t('course.navItem.landing_page.submit')}</span>
|
||||
<RocketIcon size={16} class="custom" />
|
||||
</Button.Root>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Waitlist Section -->
|
||||
{#if $landingPageSettings.mailinglist.show}
|
||||
<section id="waitlist" transition:fade class="mx-auto my-10 w-[95%] max-w-6xl">
|
||||
<div class="ui:bg-primary gap-4 rounded-lg px-4 py-14 md:px-10 lg:flex-row lg:items-center">
|
||||
<div class="mx-auto flex max-w-[700px] flex-col lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="w-3/5">
|
||||
<h1 class="mt-0 mb-5 text-4xl text-white">
|
||||
{$landingPageSettings.mailinglist.title}
|
||||
</h1>
|
||||
<p class="text-md text-white">
|
||||
{$landingPageSettings.mailinglist.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<form onsubmit={preventDefault(handleSubmit)}>
|
||||
<div class="flex max-w-60 flex-col items-center">
|
||||
{#if success}
|
||||
<p class="text-white">{$t('course.navItem.landing_page.successful_sub')}</p>
|
||||
{:else}
|
||||
<Input
|
||||
bind:value={email}
|
||||
type="email"
|
||||
placeholder={$t('course.navItem.landing_page.enter')}
|
||||
required={true}
|
||||
disabled={isAdding}
|
||||
/>
|
||||
<Button.Root class="my-1 mt-2 w-full" variant="outline" type="submit" loading={isAdding}>
|
||||
{$landingPageSettings.mailinglist.buttonLabel}
|
||||
</Button.Root>
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Footer Section -->
|
||||
|
||||
{#if $landingPageSettings.footer.show}
|
||||
<footer
|
||||
id="footer"
|
||||
class="my-10 flex w-full flex-col items-center justify-center border-t border-r-0 border-b-0 border-l-0 border-gray-300 px-5 py-10 md:py-3"
|
||||
>
|
||||
<ul class="flex w-11/12 flex-col items-center sm:flex-row">
|
||||
<div class="logo">
|
||||
<a href="/" title={`Go to ${org.name} Home`} id="logo" data-hveid="8" class="flex items-center">
|
||||
<img
|
||||
src={org.avatarUrl || '/logo-192.png'}
|
||||
alt={`${org.name} logo`}
|
||||
class="mx-auto inline-block max-h-10 w-10 rounded"
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
/>
|
||||
<h3 class="ml-3 text-xl text-black">{org.name}</h3>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<span class="flex-grow"></span>
|
||||
|
||||
<div class="mt-5 flex gap-2 sm:mt-0">
|
||||
{#if $landingPageSettings.footer.facebook}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.facebook}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.facebook')}
|
||||
id="logo-fb"
|
||||
data-hveid="8"
|
||||
>
|
||||
<FacebookIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
{#if $landingPageSettings.footer.twitter}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.twitter}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.twitter')}
|
||||
id="logo-tw"
|
||||
data-hveid="8"
|
||||
>
|
||||
<TwitterIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
|
||||
{#if $landingPageSettings.footer.linkedin}
|
||||
<li>
|
||||
<a
|
||||
href={$landingPageSettings.footer.linkedin}
|
||||
target="_blank"
|
||||
title={$t('settings.landing_page.footer.linkedin')}
|
||||
id="logo-ln"
|
||||
data-hveid="8"
|
||||
>
|
||||
<LinkedinIcon size={16} />
|
||||
</a>
|
||||
</li>
|
||||
{/if}
|
||||
</div>
|
||||
</ul>
|
||||
</footer>
|
||||
{/if}
|
||||
</main>
|
||||
<ThemeComponent {...landingPageProps} />
|
||||
{/if}
|
||||
|
||||
@@ -1,116 +1,7 @@
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable({
|
||||
header: {
|
||||
title: 'Web Development Courses for',
|
||||
titleHighlight: 'Everyone',
|
||||
subtitle: 'Practical project-based courses that are easy to understand and straight to the point',
|
||||
action: {
|
||||
label: 'Start learning Today',
|
||||
link: '#contact',
|
||||
redirect: false
|
||||
},
|
||||
banner: {
|
||||
video: '',
|
||||
image: '/images/learn-on-cio.jpg',
|
||||
type: 'image',
|
||||
show: true
|
||||
},
|
||||
background: {
|
||||
image: '',
|
||||
show: true
|
||||
},
|
||||
show: true
|
||||
},
|
||||
aboutUs: {
|
||||
title: 'Our Story',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.',
|
||||
imageUrl: '/images/riverside.jpeg',
|
||||
show: true
|
||||
},
|
||||
courses: {
|
||||
title: 'Explore our',
|
||||
titleHighlight: 'Courses',
|
||||
subtitle: 'Find courses you will love from best teachers all over the world🌎.',
|
||||
show: true
|
||||
},
|
||||
faq: {
|
||||
title: 'Frequently Asked Questions.',
|
||||
questions: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Lorem ipsum dolor sit, amet con',
|
||||
content:
|
||||
'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Repudiandae, quaerat cum. Ullam similique quae dicta ipsum atque quam fugit iusto eligendi magni voluptatum aut, exercitationem deserunt vitae iste rem sunt!\nLorem ipsum, dolor sit amet consectetur adipisicing elit. Maiores obcaecati veritatis tenetur in, nihil repellat quasi cumque sed molestias id reiciendis harum doloremque minus fugit quia cum dolores quas pariatur.'
|
||||
}
|
||||
],
|
||||
show: true
|
||||
},
|
||||
contact: {
|
||||
title: 'Have you got any',
|
||||
titleHighlight: 'Question?',
|
||||
subtitle: 'Reach out to us with any question you might have.',
|
||||
address: 'Ashwoodfield House Farm, Kidderminster Rd, Stourbridge, Kingswinford DY6 0AA',
|
||||
phone: '+4407339904995',
|
||||
email: '[email protected]',
|
||||
show: true
|
||||
},
|
||||
mailinglist: {
|
||||
title: 'Join our mailing list',
|
||||
subtitle:
|
||||
'We are constantly releasing new courses and sharing them with our email list. Subscribe to get notified once we release a new course',
|
||||
buttonLabel: 'Subscribe',
|
||||
show: true
|
||||
},
|
||||
customLinks: {
|
||||
show: true,
|
||||
links: [
|
||||
{
|
||||
id: 1,
|
||||
label: 'About Us',
|
||||
url: '#aboutus',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'Courses',
|
||||
url: '/courses',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'FAQ',
|
||||
url: '#faq',
|
||||
openInNewTab: false
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'Contact Us',
|
||||
url: '#contact',
|
||||
openInNewTab: false
|
||||
}
|
||||
]
|
||||
},
|
||||
footer: {
|
||||
facebook: '#',
|
||||
instagram: '#',
|
||||
twitter: '#',
|
||||
linkedin: '#',
|
||||
show: true
|
||||
}
|
||||
});
|
||||
import type { OrgLandingPageJson } from '$lib/utils/types/org';
|
||||
import { createDefaultLandingPageSettings } from '$features/org/utils/landing-page';
|
||||
|
||||
export const landingPageSettings: Writable<OrgLandingPageJson> = writable(createDefaultLandingPageSettings());
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
BoldLandingHero,
|
||||
BoldLandingNav,
|
||||
BoldLandingPage,
|
||||
ClassicLandingHero,
|
||||
ClassicLandingNav,
|
||||
ClassicLandingPage,
|
||||
MinimalLandingHero,
|
||||
MinimalLandingNav,
|
||||
MinimalLandingPage
|
||||
} from '@cio/ui/custom/org-landing-page';
|
||||
|
||||
export const landingPageThemeComponents = {
|
||||
minimal: MinimalLandingPage,
|
||||
bold: BoldLandingPage,
|
||||
classic: ClassicLandingPage
|
||||
} as const;
|
||||
|
||||
export const landingPageNavComponents = {
|
||||
minimal: MinimalLandingNav,
|
||||
bold: BoldLandingNav,
|
||||
classic: ClassicLandingNav
|
||||
} as const;
|
||||
|
||||
export const landingPageHeroComponents = {
|
||||
minimal: MinimalLandingHero,
|
||||
bold: BoldLandingHero,
|
||||
classic: ClassicLandingHero
|
||||
} as const;
|
||||
@@ -0,0 +1,397 @@
|
||||
import type { AccountOrg } from '$features/app/types';
|
||||
import type {
|
||||
OrgLandingPageCallout,
|
||||
OrgLandingPageFooterLink,
|
||||
OrgLandingPageHero,
|
||||
OrgLandingPageJson,
|
||||
OrgLandingPageNavItem,
|
||||
OrgLandingPageTheme
|
||||
} from '$lib/utils/types/org';
|
||||
import type { OrgPublicCourses } from './types';
|
||||
import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types';
|
||||
|
||||
export const landingPageThemes = ['minimal', 'bold', 'classic'] as const satisfies OrgLandingPageTheme[];
|
||||
|
||||
export const defaultLandingPageHero: OrgLandingPageHero = {
|
||||
heading: 'Become a Certified AI Engineer',
|
||||
subheading: 'Master the skills, earn your certification, and prove your expertise with hands-on training programs.',
|
||||
primaryAction: {
|
||||
label: 'Get Started',
|
||||
href: '#courses'
|
||||
},
|
||||
secondaryAction: {
|
||||
label: 'View Programs',
|
||||
href: '/courses'
|
||||
},
|
||||
image: '/images/learn-on-cio.jpg'
|
||||
};
|
||||
|
||||
export const defaultLandingPageSettings: OrgLandingPageJson = {
|
||||
theme: 'minimal',
|
||||
hero: defaultLandingPageHero,
|
||||
navItems: [
|
||||
{ label: 'Programs', href: '#courses' },
|
||||
{ label: 'Contact', href: '#contact' }
|
||||
],
|
||||
footerLinks: [
|
||||
{ label: 'Terms', href: '/terms' },
|
||||
{ label: 'Privacy', href: '/privacy' },
|
||||
{ label: 'Contact', href: '#contact' }
|
||||
],
|
||||
footerText: ''
|
||||
};
|
||||
|
||||
export function createDefaultLandingPageSettings(): OrgLandingPageJson {
|
||||
return structuredClone(defaultLandingPageSettings);
|
||||
}
|
||||
|
||||
type LegacyLandingPageJson = {
|
||||
header?: {
|
||||
title?: string;
|
||||
titleHighlight?: string;
|
||||
subtitle?: string;
|
||||
action?: {
|
||||
label?: string;
|
||||
link?: string;
|
||||
};
|
||||
};
|
||||
customLinks?: {
|
||||
links?: Array<{
|
||||
label?: string;
|
||||
url?: string;
|
||||
openInNewTab?: boolean;
|
||||
}>;
|
||||
};
|
||||
footer?: {
|
||||
facebook?: string;
|
||||
instagram?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
};
|
||||
hero?: OrgLandingPageHero;
|
||||
navItems?: OrgLandingPageNavItem[];
|
||||
footerLinks?: OrgLandingPageFooterLink[];
|
||||
footerText?: string;
|
||||
theme?: OrgLandingPageTheme;
|
||||
embed?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
code?: string;
|
||||
secondaryAction?: {
|
||||
label?: string;
|
||||
href?: string;
|
||||
};
|
||||
src?: string;
|
||||
height?: number;
|
||||
allowFullscreen?: boolean;
|
||||
sandbox?: string;
|
||||
};
|
||||
callout?: {
|
||||
heading?: string;
|
||||
description?: string;
|
||||
action?: {
|
||||
label?: string;
|
||||
href?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
function normalizeText(value: unknown, fallback = '') {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function normalizeHref(value: unknown, fallback = '#') {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function normalizeEmbedCode(embed: {
|
||||
title?: unknown;
|
||||
description?: unknown;
|
||||
code?: unknown;
|
||||
src?: unknown;
|
||||
height?: unknown;
|
||||
allowFullscreen?: unknown;
|
||||
sandbox?: unknown;
|
||||
}) {
|
||||
if (typeof embed.code === 'string' && embed.code.trim().length > 0) {
|
||||
return embed.code.trim();
|
||||
}
|
||||
|
||||
const src = normalizeHref(embed.src, '');
|
||||
if (!src) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const height = typeof embed.height === 'number' && Number.isFinite(embed.height) ? embed.height : 560;
|
||||
const allowFullscreen = embed.allowFullscreen !== false;
|
||||
const title = normalizeText(embed.title, 'Section title');
|
||||
const sandbox =
|
||||
typeof embed.sandbox === 'string' && embed.sandbox.trim().length > 0 ? ` sandbox="${embed.sandbox.trim()}"` : '';
|
||||
|
||||
return `<iframe title="${title}" src="${src}" width="100%" height="${height}" loading="lazy" referrerpolicy="no-referrer"${sandbox}${
|
||||
allowFullscreen ? ' allowfullscreen' : ''
|
||||
}></iframe>`;
|
||||
}
|
||||
|
||||
function normalizeCallout(callout: NonNullable<LegacyLandingPageJson['callout']>): OrgLandingPageCallout | undefined {
|
||||
const heading = normalizeText(callout.heading, '');
|
||||
if (!heading) return undefined;
|
||||
|
||||
return {
|
||||
heading,
|
||||
description: normalizeText(callout.description, ''),
|
||||
action: {
|
||||
label: normalizeText(callout.action?.label, 'Get Started'),
|
||||
href: normalizeHref(callout.action?.href, '#')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeLandingPageSettings(value: unknown): OrgLandingPageJson {
|
||||
const defaultSettings = createDefaultLandingPageSettings();
|
||||
|
||||
if (!isRecord(value)) {
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const landingPage = value as LegacyLandingPageJson;
|
||||
if (landingPage.hero && landingPage.navItems && landingPage.footerLinks && landingPage.theme) {
|
||||
return {
|
||||
theme: landingPage.theme,
|
||||
hero: {
|
||||
heading: normalizeText(landingPage.hero.heading, defaultLandingPageHero.heading),
|
||||
subheading: normalizeText(landingPage.hero.subheading, defaultLandingPageHero.subheading),
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.hero.primaryAction?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.hero.primaryAction?.href, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: landingPage.hero.secondaryAction
|
||||
? {
|
||||
label: normalizeText(
|
||||
landingPage.hero.secondaryAction.label,
|
||||
defaultLandingPageHero.secondaryAction?.label ?? ''
|
||||
),
|
||||
href: normalizeHref(
|
||||
landingPage.hero.secondaryAction.href,
|
||||
defaultLandingPageHero.secondaryAction?.href ?? '#'
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
image: normalizeText(landingPage.hero.image, '')
|
||||
},
|
||||
navItems: landingPage.navItems
|
||||
.map((navItem) => ({
|
||||
label: normalizeText(navItem.label, ''),
|
||||
href: normalizeHref(navItem.href, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0),
|
||||
footerLinks: landingPage.footerLinks
|
||||
.map((footerLink) => ({
|
||||
label: normalizeText(footerLink.label, ''),
|
||||
href: normalizeHref(footerLink.href, '#')
|
||||
}))
|
||||
.filter((footerLink) => footerLink.label.length > 0),
|
||||
footerText: normalizeText(landingPage.footerText, defaultSettings.footerText),
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed),
|
||||
secondaryAction: landingPage.embed.secondaryAction
|
||||
? {
|
||||
label: normalizeText(landingPage.embed.secondaryAction.label, ''),
|
||||
href: normalizeHref(landingPage.embed.secondaryAction.href, '#')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined,
|
||||
callout: landingPage.callout ? normalizeCallout(landingPage.callout) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
if (landingPage.hero) {
|
||||
return {
|
||||
theme: landingPage.theme ?? 'minimal',
|
||||
hero: {
|
||||
heading: normalizeText(landingPage.hero.heading, defaultLandingPageHero.heading),
|
||||
subheading: normalizeText(landingPage.hero.subheading, defaultLandingPageHero.subheading),
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.hero.primaryAction?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.hero.primaryAction?.href, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: landingPage.hero.secondaryAction
|
||||
? {
|
||||
label: normalizeText(
|
||||
landingPage.hero.secondaryAction.label,
|
||||
defaultLandingPageHero.secondaryAction?.label ?? ''
|
||||
),
|
||||
href: normalizeHref(
|
||||
landingPage.hero.secondaryAction.href,
|
||||
defaultLandingPageHero.secondaryAction?.href ?? '#'
|
||||
)
|
||||
}
|
||||
: defaultLandingPageHero.secondaryAction,
|
||||
image: normalizeText(landingPage.hero.image, defaultLandingPageHero.image ?? '')
|
||||
},
|
||||
navItems: landingPage.navItems?.length
|
||||
? landingPage.navItems
|
||||
.map((navItem) => ({
|
||||
label: normalizeText(navItem.label, ''),
|
||||
href: normalizeHref(navItem.href, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0)
|
||||
: [...defaultSettings.navItems],
|
||||
footerLinks: landingPage.footerLinks?.length
|
||||
? landingPage.footerLinks
|
||||
.map((footerLink) => ({
|
||||
label: normalizeText(footerLink.label, ''),
|
||||
href: normalizeHref(footerLink.href, '#')
|
||||
}))
|
||||
.filter((footerLink) => footerLink.label.length > 0)
|
||||
: [...defaultSettings.footerLinks],
|
||||
footerText: normalizeText(landingPage.footerText, defaultSettings.footerText),
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed)
|
||||
}
|
||||
: undefined,
|
||||
callout: landingPage.callout ? normalizeCallout(landingPage.callout) : undefined
|
||||
};
|
||||
}
|
||||
|
||||
const fallbackHeading = normalizeText(landingPage.header?.title, defaultLandingPageHero.heading);
|
||||
const fallbackHighlight = normalizeText(landingPage.header?.titleHighlight, '');
|
||||
const fallbackSubheading = normalizeText(landingPage.header?.subtitle, defaultLandingPageHero.subheading);
|
||||
|
||||
return {
|
||||
...defaultSettings,
|
||||
hero: {
|
||||
heading: fallbackHighlight ? `${fallbackHeading} ${fallbackHighlight}` : fallbackHeading,
|
||||
subheading: fallbackSubheading,
|
||||
primaryAction: {
|
||||
label: normalizeText(landingPage.header?.action?.label, defaultLandingPageHero.primaryAction.label),
|
||||
href: normalizeHref(landingPage.header?.action?.link, defaultLandingPageHero.primaryAction.href)
|
||||
},
|
||||
secondaryAction: defaultLandingPageHero.secondaryAction,
|
||||
image: defaultLandingPageHero.image
|
||||
},
|
||||
navItems: landingPage.customLinks?.links?.length
|
||||
? landingPage.customLinks.links
|
||||
.map((link) => ({
|
||||
label: normalizeText(link.label, ''),
|
||||
href: normalizeHref(link.url, '#')
|
||||
}))
|
||||
.filter((navItem) => navItem.label.length > 0)
|
||||
: [...defaultSettings.navItems],
|
||||
footerLinks: [
|
||||
landingPage.footer?.facebook ? { label: 'Facebook', href: landingPage.footer.facebook } : null,
|
||||
landingPage.footer?.instagram ? { label: 'Instagram', href: landingPage.footer.instagram } : null,
|
||||
landingPage.footer?.twitter ? { label: 'Twitter', href: landingPage.footer.twitter } : null,
|
||||
landingPage.footer?.linkedin ? { label: 'LinkedIn', href: landingPage.footer.linkedin } : null
|
||||
].filter(Boolean) as OrgLandingPageFooterLink[],
|
||||
footerText: defaultSettings.footerText,
|
||||
embed: landingPage.embed
|
||||
? {
|
||||
title: normalizeText(landingPage.embed.title, 'Section title'),
|
||||
description: normalizeText(landingPage.embed.description, ''),
|
||||
code: normalizeEmbedCode(landingPage.embed),
|
||||
secondaryAction: landingPage.embed.secondaryAction
|
||||
? {
|
||||
label: normalizeText(landingPage.embed.secondaryAction.label, ''),
|
||||
href: normalizeHref(landingPage.embed.secondaryAction.href, '#')
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
export function mapPublicCoursesToLandingPageCourses(courses: OrgPublicCourses): OrgLandingPageProps['courses'] {
|
||||
return courses.map((course) => {
|
||||
const courseRecord = course as Record<string, unknown>;
|
||||
const metadataRecord = isRecord(courseRecord.metadata) ? courseRecord.metadata : undefined;
|
||||
const tags = Array.isArray(courseRecord.tags)
|
||||
? (courseRecord.tags as OrgLandingPageProps['courses'][number]['tags'])
|
||||
: undefined;
|
||||
const courseSlug =
|
||||
typeof courseRecord.slug === 'string' && courseRecord.slug.length > 0 ? courseRecord.slug : course.id;
|
||||
const courseCost = typeof courseRecord.cost === 'number' ? courseRecord.cost : undefined;
|
||||
const courseCurrency = typeof courseRecord.currency === 'string' ? courseRecord.currency : undefined;
|
||||
const lessonCount = typeof courseRecord.lessonCount === 'number' ? courseRecord.lessonCount : undefined;
|
||||
const exerciseCount = typeof courseRecord.exerciseCount === 'number' ? courseRecord.exerciseCount : undefined;
|
||||
const totalStudents = typeof courseRecord.totalStudents === 'number' ? courseRecord.totalStudents : undefined;
|
||||
const image = typeof courseRecord.image === 'string' ? courseRecord.image : undefined;
|
||||
const logo = typeof courseRecord.logo === 'string' ? courseRecord.logo : undefined;
|
||||
const price = typeof courseRecord.price === 'string' ? courseRecord.price : undefined;
|
||||
const duration = typeof courseRecord.duration === 'string' ? courseRecord.duration : undefined;
|
||||
const level = typeof courseRecord.level === 'string' ? courseRecord.level : undefined;
|
||||
const description = typeof courseRecord.description === 'string' ? courseRecord.description : '';
|
||||
const type = typeof courseRecord.type === 'string' ? courseRecord.type : undefined;
|
||||
const isPublished = typeof courseRecord.isPublished === 'boolean' ? courseRecord.isPublished : true;
|
||||
|
||||
return {
|
||||
id: course.id,
|
||||
slug: typeof courseRecord.slug === 'string' ? courseRecord.slug : undefined,
|
||||
logo: logo ?? null,
|
||||
title: typeof courseRecord.title === 'string' ? courseRecord.title : '',
|
||||
description,
|
||||
type,
|
||||
isPublished,
|
||||
cost: courseCost,
|
||||
currency: courseCurrency,
|
||||
lessonCount,
|
||||
exerciseCount,
|
||||
totalStudents,
|
||||
metadata: metadataRecord
|
||||
? {
|
||||
discount: typeof metadataRecord.discount === 'number' ? metadataRecord.discount : undefined,
|
||||
showDiscount: typeof metadataRecord.showDiscount === 'boolean' ? metadataRecord.showDiscount : undefined
|
||||
}
|
||||
: undefined,
|
||||
tags,
|
||||
image,
|
||||
link: `/course/${courseSlug}`,
|
||||
price,
|
||||
duration,
|
||||
level
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildOrgLandingPageProps(
|
||||
org: AccountOrg,
|
||||
landingpage: unknown,
|
||||
courses: OrgPublicCourses,
|
||||
hasMoreCourses = false,
|
||||
authAction?: OrgLandingPageProps['authAction']
|
||||
): OrgLandingPageProps {
|
||||
const normalizedLandingPage = normalizeLandingPageSettings(landingpage);
|
||||
|
||||
return {
|
||||
orgName: org.name,
|
||||
logoUrl: org.avatarUrl || undefined,
|
||||
authAction,
|
||||
...normalizedLandingPage,
|
||||
courses: mapPublicCoursesToLandingPageCourses(courses),
|
||||
hasMoreCourses
|
||||
};
|
||||
}
|
||||
|
||||
export type LandingPageThemeKey = (typeof landingPageThemes)[number];
|
||||
|
||||
export function importThemeComponent(theme: LandingPageThemeKey) {
|
||||
switch (theme) {
|
||||
case 'bold':
|
||||
return import('@cio/ui/custom/org-landing-page/bold.svelte');
|
||||
case 'classic':
|
||||
return import('@cio/ui/custom/org-landing-page/classic.svelte');
|
||||
default:
|
||||
return import('@cio/ui/custom/org-landing-page/minimal.svelte');
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,15 @@
|
||||
import { classroomio, type InferRequestType, type InferResponseType } from '$lib/utils/services/api';
|
||||
|
||||
export type GetOrganizationRequest = typeof classroomio.organization.$get;
|
||||
export type GetOrganizationResponse = InferResponseType<GetOrganizationRequest> | null;
|
||||
export type GetOrganizationSuccess = Extract<InferResponseType<GetOrganizationRequest>, { success: true }>;
|
||||
export type OrganizationRecord = GetOrganizationSuccess['data'][number];
|
||||
|
||||
export type GetFirstOrganizationRequest = typeof classroomio.organization.first.$get;
|
||||
export type GetFirstOrganizationResponse = InferResponseType<GetFirstOrganizationRequest> | null;
|
||||
export type GetFirstOrganizationSuccess = Extract<InferResponseType<GetFirstOrganizationRequest>, { success: true }>;
|
||||
export type FirstOrganizationRecord = GetFirstOrganizationSuccess['data'][number];
|
||||
|
||||
// Team member types
|
||||
export type GetTeamRequest = (typeof classroomio.organization)['team']['$get'];
|
||||
export type OrganizationTeamResponse = InferResponseType<GetTeamRequest> | null;
|
||||
@@ -39,7 +49,8 @@ export type OrganizationAudienceMember = OrganizationAudience[number];
|
||||
export type GetOrgPublicCoursesRequest = typeof classroomio.organization.courses.public.$get;
|
||||
export type OrgPublicCoursesResponse = InferResponseType<GetOrgPublicCoursesRequest> | null;
|
||||
export type OrgPublicCoursesSuccess = Extract<InferResponseType<GetOrgPublicCoursesRequest>, { success: true }>;
|
||||
export type OrgPublicCourses = OrgPublicCoursesSuccess['data'];
|
||||
export type OrgPublicCoursesData = OrgPublicCoursesSuccess['data'];
|
||||
export type OrgPublicCourses = OrgPublicCoursesData['courses'];
|
||||
|
||||
// dashboard analytics types
|
||||
export type GetDashStatsRequest = typeof classroomio.dash.stats.$get;
|
||||
|
||||
Reference in New Issue
Block a user