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:
Rotimi Best
2026-04-12 08:58:41 +01:00
parent 5a8f50f67b
commit d6e812d08b
573 changed files with 31964 additions and 8708 deletions
@@ -0,0 +1,145 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
import BoldHero from './bold/hero.svelte';
import { Button } from '../../base/button';
import * as Card from '../../base/card';
import { BlurFade } from '../animation/blurfade';
import { BorderBeam } from '../animation/border-beam';
import { DotPattern } from '../animation/dot-pattern';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<BoldHero {hero} />
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden">
<DotPattern class="ui:opacity-[0.15]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">Latest Courses</h2>
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<Card.Root
class="ui:group ui:relative ui:overflow-hidden ui:bg-background ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-border/50 {disableCourseLinks
? ''
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
>
<BorderBeam
size={120}
duration={8}
colorFrom="var(--primary)"
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
/>
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<span>{course.duration}</span>
</div>
{/if}
{#if primaryTag}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TagIcon class="ui:size-3.5 ui:text-primary" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg"
>{course.price || formatCurrency(course.cost, course.currency)}</span
>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
Details
</Button>
</div>
</Card.Content>
</Card.Root>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
size="lg"
class="ui:rounded-xl ui:px-8"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</div>
</section>
</main>
<OrgLandingPageEmbed {embed} variant="bold" />
<OrgLandingPageCallout {callout} variant="bold" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="bold" />
</div>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
interface Props {
hero: OrgLandingPageProps['hero'];
}
let { hero }: Props = $props();
</script>
<section class="ui:py-10 ui:px-6 ui:max-w-7xl ui:mx-auto ui:grid ui:lg:grid-cols-2 ui:gap-16 ui:items-center">
<div>
<BlurFade delay={0} once={true} class="ui:mb-6">
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<p class="ui:text-xl ui:text-muted-foreground ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:flex-wrap ui:gap-4">
<Button
href={hero.primaryAction.href}
size="lg"
class="ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20 ui:hover:-translate-y-1 ui:transition-all"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="bold" />
{/if}
</div>
</BlurFade>
</div>
{#if hero.image}
<BlurFade delay={0.2} once={true} class="ui:relative ui:hidden ui:lg:block">
<div
class="ui:absolute ui:inset-0 ui:bg-primary/5 ui:rounded-3xl ui:transform ui:translate-x-4 ui:translate-y-4"
></div>
<img src={hero.image} alt="Hero" class="ui:relative ui:rounded-3xl ui:w-full ui:h-[500px] ui:object-cover" />
</BlurFade>
{/if}
</section>
@@ -0,0 +1,43 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<header class="ui:sticky ui:top-0 ui:z-10 ui:bg-background/90 ui:backdrop-blur-xl ui:border-b ui:border-border/40">
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:py-5 ui:max-w-7xl ui:mx-auto">
<a href="/" class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:gap-8 ui:items-center">
{#each navItems as item}
<a
href={item.href}
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-primary ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button href={authAction.href} variant="outline" class="ui:rounded-xl ui:font-bold">
{authAction.label}
</Button>
{/if}
</div>
</div>
</header>
@@ -0,0 +1,70 @@
<script lang="ts">
import type { LandingPageCallout } from './types';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: 'minimal' | 'bold' | 'classic' } =
$props();
const sectionClasses: Record<string, string> = {
minimal: 'ui:py-20 ui:px-6',
bold: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background'
};
const headingClasses: Record<string, string> = {
minimal: 'ui:text-3xl ui:font-semibold ui:tracking-tight',
bold: 'ui:text-4xl ui:lg:text-5xl ui:font-black ui:tracking-tighter',
classic: 'ui:text-3xl ui:font-bold ui:tracking-tight'
};
const descriptionClasses: Record<string, string> = {
minimal: 'ui:text-lg ui:text-foreground/70',
bold: 'ui:text-xl ui:text-background/70',
classic: 'ui:text-lg ui:text-background/80'
};
const buttonClasses: Record<string, string> = {
minimal: 'ui:rounded-full',
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-primary/20',
classic: 'ui:px-8 ui:font-semibold'
};
const minimalBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 10%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 4%, var(--background)) 100%
)
`;
</script>
{#if callout}
<section
class={sectionClasses[variant]}
style={variant === 'minimal' ? `background: ${minimalBackground};` : undefined}
>
<div class="ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
<BlurFade delay={0} once={true}>
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
</BlurFade>
{#if callout.description}
<BlurFade delay={0.15} once={true}>
<p class={descriptionClasses[variant]}>{callout.description}</p>
</BlurFade>
{/if}
<BlurFade delay={0.3} once={true}>
<Button
href={callout.action.href}
size="lg"
class={buttonClasses[variant]}
variant={variant === 'bold' || variant === 'classic' ? 'secondary' : 'default'}
>
{callout.action.label}
</Button>
</BlurFade>
</div>
</section>
{/if}
@@ -0,0 +1,152 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
import ClassicHero from './classic/hero.svelte';
import { Button } from '../../base/button';
import * as Item from '../../base/item';
import { BlurFade } from '../animation/blurfade';
import { CourseCard } from '../course-card';
import TagIcon from '@lucide/svelte/icons/tag';
import { calcCourseDiscount, formatExerciseCountLabel, getPrimaryCourseTag } from './landing-page-utils';
import { cn } from '../../tools';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
const COURSE_TAG_ICON: Component = TagIcon;
</script>
<div class="ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans">
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<ClassicHero {hero} />
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
<div class="ui:text-center ui:mb-8">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">Featured Programs</h2>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
</BlurFade>
</div>
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
{@const pricingData = {
cost: course.cost ?? 0,
discount: course.metadata?.discount ?? 0,
showDiscount: !!course.metadata?.showDiscount
}}
{@const displayCost = calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount)}
{@const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD'
})}
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
<CourseCard
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
bannerImage={course.logo || ''}
bannerAlt={course.title}
title={course.title}
description={course.description}
typeBadge={primaryTag
? {
label: primaryTag.name,
icon: COURSE_TAG_ICON,
iconClass: primaryTag.color ? '' : 'custom ui:text-primary'
}
: undefined}
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
>
{#snippet tags()}
{#if course.tags && course.tags.length > 0}
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
{#each course.tags as tag (tag.id)}
<Item.SubDescription
class="ui:border-border ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
>
<span
class="ui:bg-primary/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="ui:flex ui:justify-between ui:w-full">
<div class="ui:w-[80%]">
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
<span>{course.lessonCount || 0} lessons</span>
&
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
</p>
<div class="ui:py-2 ui:text-xs">
{#if course.price != null && String(course.price).length > 0}
<span class="ui:px-2">{course.price}</span>
{:else if !displayCost}
<span class="ui:px-2">Free</span>
{:else if pricingData.showDiscount}
<span class="ui:px-2">
{currencyFormatter.format(displayCost)}
<span class="line-through">
{currencyFormatter.format(pricingData.cost)}
</span>
</span>
{:else}
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
{/if}
</div>
</div>
</div>
{/snippet}
</CourseCard>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-12 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
class="ui:px-8"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</section>
</main>
<OrgLandingPageEmbed {embed} variant="classic" />
<OrgLandingPageCallout {callout} variant="classic" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="classic" />
</div>
@@ -0,0 +1,49 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { BlurIn } from '../../animation/blurin';
import { DotPattern } from '../../animation/dot-pattern';
interface Props {
hero: OrgLandingPageProps['hero'];
}
let { hero }: Props = $props();
</script>
<section
class="ui:relative ui:bg-foreground ui:text-background ui:overflow-hidden ui:py-24 ui:sm:py-32 ui:px-6 ui:lg:px-8"
>
{#if hero.image}
<div class="ui:absolute ui:inset-0 ui:opacity-10">
<img src={hero.image} alt="Background" class="ui:w-full ui:h-full ui:object-cover ui:grayscale" />
</div>
{/if}
<DotPattern fillColor="rgb(255 255 255 / 0.35)" class="ui:opacity-[0.06]" />
<div class="ui:relative ui:max-w-3xl ui:mx-auto ui:text-center">
<BlurIn
class="ui:text-4xl ui:sm:text-5xl ui:lg:text-6xl ui:font-bold ui:tracking-tight ui:mb-6 ui:leading-tight ui:drop-shadow-none"
>
{hero.heading}
</BlurIn>
<BlurFade delay={0.15} once={true}>
<p class="ui:text-lg ui:sm:text-xl ui:text-background/80 ui:mb-10">{hero.subheading}</p>
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:flex-col ui:sm:flex-row ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:px-8 ui:font-semibold">
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="classic"
/>
{/if}
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,47 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<header class="ui:bg-foreground ui:text-background">
<div class="ui:flex ui:items-center ui:justify-between ui:px-6 ui:lg:px-8 ui:h-20 ui:max-w-7xl ui:mx-auto">
<a href="/" class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:space-x-8">
{#each navItems as item}
<a
href={item.href}
class="ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button
href={authAction.href}
variant="outline"
class="ui:bg-transparent ui:text-background ui:border-background/40 ui:hover:bg-background/10 ui:hover:text-background ui:hover:border-background/60"
>
{authAction.label}
</Button>
{/if}
</div>
</div>
</header>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
import SecondaryActionButton from './secondary-action-button.svelte';
import { BlurFade } from '../animation/blurfade';
let { embed, variant = 'minimal' }: { embed?: LandingPageEmbed; variant?: OrgLandingPageTheme } = $props();
</script>
{#if embed}
<section class="ui:py-12 ui:px-6 ui:md:px-8">
<div class="ui:mx-auto ui:flex ui:max-w-7xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
<BlurFade delay={0} once={true}>
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
</BlurFade>
{#if embed.description?.trim()}
<BlurFade delay={0.12} once={true}>
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-muted-foreground">
{embed.description}
</p>
</BlurFade>
{/if}
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
<BlurFade delay={0.2} once={true}>
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
</BlurFade>
{/if}
{#if embed.code}
<BlurFade delay={0.28} once={true}>
<div class="ui:flex ui:w-full ui:justify-center">
{@html embed.code}
</div>
</BlurFade>
{/if}
</div>
</section>
{/if}
@@ -0,0 +1,14 @@
export { default as MinimalLandingPage } from './minimal.svelte';
export { default as BoldLandingPage } from './bold.svelte';
export { default as ClassicLandingPage } from './classic.svelte';
export { default as BoldLandingNav } from './bold/nav.svelte';
export { default as BoldLandingHero } from './bold/hero.svelte';
export { default as ClassicLandingNav } from './classic/nav.svelte';
export { default as ClassicLandingHero } from './classic/hero.svelte';
export { default as MinimalLandingNav } from './minimal/nav.svelte';
export { default as MinimalLandingHero } from './minimal/hero.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte';
export { default as OrgLandingPageFooter } from './landing-page-footer.svelte';
export { default as SecondaryActionButton } from './secondary-action-button.svelte';
export * from './types';
@@ -0,0 +1,52 @@
<script lang="ts">
import type { OrgLandingPageProps, OrgLandingPageTheme } from './types';
interface Props {
orgName: string;
logoUrl?: string;
footerLinks: OrgLandingPageProps['footerLinks'];
footerText: string;
variant: OrgLandingPageTheme;
}
let { orgName, logoUrl, footerLinks, footerText, variant }: Props = $props();
</script>
{#if variant === 'minimal'}
<footer
class="ui:py-12 ui:px-8 ui:max-w-[1200px] ui:mx-auto ui:border-t ui:border-border/50 ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-4 ui:text-sm ui:text-muted-foreground"
>
<a href="/" class="ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
{#each footerLinks as link}
<a href={link.href} class="ui:hover:text-foreground ui:transition-colors">{link.label}</a>
{/each}
</div>
<p>{footerText}</p>
</footer>
{:else}
<footer class="ui:bg-foreground ui:text-background/60 ui:py-16 ui:px-6">
<div class="ui:max-w-7xl ui:mx-auto ui:flex ui:flex-col ui:md:flex-row ui:items-center ui:justify-between ui:gap-8">
<a
href="/"
class="ui:font-black ui:text-2xl ui:text-background ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto ui:grayscale ui:brightness-200" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:flex-wrap ui:justify-center ui:gap-8">
{#each footerLinks as link}
<a href={link.href} class="ui:hover:text-background ui:font-medium ui:transition-colors">{link.label}</a>
{/each}
</div>
<p class="ui:text-sm">{footerText}</p>
</div>
</footer>
{/if}
@@ -0,0 +1,19 @@
import type { CourseItem } from './types';
export function getPrimaryCourseTag(course: CourseItem) {
return course.tags?.[0];
}
/** Plain copy for org landing templates (no dashboard i18n). */
export function formatExerciseCountLabel(count: number) {
if (count === 1) return '1 exercise';
return `${count} exercises`;
}
/** Mirrors dashboard `calcCourseDiscount` for landing-page course cards. */
export function calcCourseDiscount(percent = 0, cost: number, showDiscount: boolean) {
if (!percent || !showDiscount) return cost;
const discountAmount = (percent / 100) * cost;
const discountedPrice = cost - discountAmount;
return Math.round(discountedPrice);
}
@@ -0,0 +1,122 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import MinimalNav from './minimal/nav.svelte';
import MinimalHero from './minimal/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import * as Card from '../../base/card';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<div class="ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
<main>
<MinimalHero {hero}>
{#snippet navigation()}
<MinimalNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</MinimalHero>
<section class="ui:py-20 ui:px-4">
<div class="ui:max-w-[1200px] ui:mx-auto">
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">Our Courses</h2>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6">
{#each courses as course, index (course.id)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<a
href={disableCourseLinks ? undefined : course.link || `/course/${course.slug || course.id}`}
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<Card.Root
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
? ''
: 'ui:hover:border-border ui:transition-colors'}"
>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
{course.description}
</Card.Description>
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
{#if course.duration}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<ClockIcon class="ui:size-4" />
<span>{course.duration}</span>
</div>
{/if}
{#if primaryTag}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<TagIcon class="ui:size-4" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
<div class="ui:ml-auto ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</div>
</div>
</Card.Content>
</Card.Root>
</a>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-10 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
size="lg"
disabled={disableCourseLinks}
>
View more courses
</Button>
</div>
{/if}
</div>
</section>
</main>
<OrgLandingPageEmbed {embed} variant="minimal" />
<OrgLandingPageCallout {callout} variant="minimal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="minimal" />
</div>
@@ -0,0 +1,68 @@
<script lang="ts">
import type { OrgLandingPageProps } from '../types';
import type { Snippet } from 'svelte';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { DotPattern } from '../../animation/dot-pattern';
import { ShinnyText } from '../../animation/shinny-text';
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
}
let { hero, navigation }: Props = $props();
const heroGradientBackground = `
radial-gradient(
circle at top center,
color-mix(in oklab, var(--primary) 24%, transparent) 0%,
transparent 58%
),
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 12%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 6%, var(--background)) 48%,
var(--background) 100%
)
`;
</script>
<section
class="ui:relative ui:overflow-hidden ui:px-4 ui:pt-4 ui:pb-16 ui:md:pb-24"
style={`background: ${heroGradientBackground};`}
>
<DotPattern class="ui:opacity-[0.08]" />
<div class="ui:relative ui:max-w-[1200px] ui:mx-auto">
{@render navigation()}
</div>
<div class="ui:relative ui:py-20 ui:text-center ui:max-w-4xl ui:mx-auto">
<BlurFade delay={0} once={true}>
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<ShinnyText
shimmerWidth={120}
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-foreground/72"
>
{hero.subheading}
</ShinnyText>
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:items-center ui:justify-center ui:gap-4">
<Button href={hero.primaryAction.href} size="lg" class="ui:rounded-full">
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="minimal"
/>
{/if}
</div>
</BlurFade>
</div>
</section>
@@ -0,0 +1,50 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
const navigationGradientBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 16%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 8%, var(--background)) 100%
)
`;
</script>
<header
class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:px-6 ui:py-4 ui:rounded-full ui:border ui:border-border/60 ui:backdrop-blur-sm"
style={`background: ${navigationGradientBackground};`}
>
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
{orgName}
</a>
<div class="ui:flex ui:items-center ui:gap-4">
<nav class="ui:hidden ui:md:flex ui:gap-8">
{#each navItems as item}
<a href={item.href} class="ui:text-sm ui:text-foreground/70 ui:hover:text-foreground ui:transition-colors"
>{item.label}</a
>
{/each}
</nav>
{#if authAction}
<Button href={authAction.href} variant="outline" class="ui:rounded-full">
{authAction.label}
</Button>
{/if}
</div>
</header>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { OrgLandingPageTheme } from './types';
import { Button } from '../../base/button';
interface Props {
href: string;
label: string;
variant: OrgLandingPageTheme;
}
let { href, label, variant }: Props = $props();
const themeButtonClasses: Record<
OrgLandingPageTheme,
{
className: string;
variant: 'outline';
}
> = {
minimal: {
className: 'ui:rounded-full',
variant: 'outline'
},
bold: {
className:
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-primary ui:transition-all',
variant: 'outline'
},
classic: {
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
variant: 'outline'
}
};
</script>
<Button {href} variant={themeButtonClasses[variant].variant} size="lg" class={themeButtonClasses[variant].className}>
{label}
</Button>
@@ -0,0 +1,78 @@
export type NavItem = { label: string; href: string };
export type OrgLandingPageTheme = 'minimal' | 'bold' | 'classic';
export type CourseItem = {
id: string;
slug?: string;
logo?: string | null;
title: string;
description: string;
type?: string;
isPublished?: boolean;
cost?: number;
currency?: string;
lessonCount?: number;
exerciseCount?: number;
totalStudents?: number;
metadata?: {
discount?: number;
showDiscount?: boolean;
};
tags?: Array<{
id: string;
name: string;
slug: string;
color?: string | null;
}>;
// Fallbacks for specific templates that might still want to use these
image?: string;
link?: string;
price?: string;
duration?: string;
level?: string;
};
export type FooterLink = { label: string; href: string };
export type LandingPageEmbed = {
title: string;
description?: string;
code: string;
secondaryAction?: {
label: string;
href: string;
};
};
export type LandingPageCallout = {
heading: string;
description: string;
action: {
label: string;
href: string;
};
};
export interface OrgLandingPageProps {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
hero: {
heading: string;
subheading: string;
primaryAction: { label: string; href: string };
secondaryAction?: { label: string; href: string };
image?: string;
};
courses: CourseItem[];
hasMoreCourses?: boolean;
disableCourseLinks?: boolean;
embed?: LandingPageEmbed;
callout?: LandingPageCallout;
footerLinks: FooterLink[];
footerText: string;
}