feat: certificates, landing pages, ai tutor, website update, compliance

This commit is contained in:
Rotimi Best
2026-05-18 03:47:22 +01:00
parent c31ad63955
commit ac3a2c14a7
644 changed files with 56050 additions and 5439 deletions
@@ -1,6 +1,7 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
@@ -12,7 +13,7 @@
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';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
@@ -25,8 +26,8 @@
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
links,
footer
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
@@ -49,6 +50,7 @@
</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 courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<Card.Root
@@ -82,6 +84,15 @@
<span>{course.duration}</span>
</div>
{/if}
{#if courseTypeMeta}
{@const TypeIcon = courseTypeMeta.icon}
<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"
>
<TypeIcon class={courseTypeMeta.iconClass} />
<span>{courseTypeMeta.label}</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"
@@ -137,9 +148,11 @@
</section>
</main>
<OrgLandingPageLinks {links} variant="bold" />
<OrgLandingPageEmbed {embed} variant="bold" />
<OrgLandingPageCallout {callout} variant="bold" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="bold" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
</div>
@@ -3,6 +3,7 @@
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
@@ -11,36 +12,38 @@
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}
disabled={hero.primaryAction.disabled ?? false}
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>
<EditableLandingSection sectionKey="hero">
<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}
disabled={hero.primaryAction.disabled ?? false}
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>
</EditableLandingSection>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
@@ -15,29 +16,34 @@
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}
<EditableLandingSection sectionKey="navigation">
<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>
</div>
</header>
</header>
</EditableLandingSection>
@@ -1,33 +1,77 @@
<script lang="ts">
import type { LandingPageCallout } from './types';
import type { LandingPageCallout, OrgLandingPageTheme } from './types';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import EditableLandingSection from './editable-section.svelte';
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: 'minimal' | 'bold' | 'classic' } =
$props();
let { callout, variant = 'minimal' }: { callout?: LandingPageCallout; variant?: OrgLandingPageTheme } = $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'
classic: 'ui:py-20 ui:px-6 ui:bg-foreground ui:text-background',
saas: 'ui:py-24 ui:md:py-28 ui:px-6 ui:relative ui:overflow-hidden',
tech: 'ui:py-24 ui:px-6 ui:bg-foreground ui:text-background',
studio:
'ui:py-32 ui:px-6 ui:relative ui:overflow-hidden ui:bg-background ui:text-foreground ui:border-t ui:border-border',
corporate: 'ui:py-20 ui:px-6 ui:bg-muted/40 ui:text-foreground ui:border-t ui:border-border',
terminal: 'ui:py-20 ui:px-6 ui:border-t ui:border-[#1c1f28] ui:text-[#e9eaed] ui:bg-[#06070a]'
};
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'
classic: 'ui:text-3xl ui:font-bold ui:tracking-tight',
saas: 'ui:text-4xl ui:md:text-5xl ui:font-bold ui:tracking-tight ui:leading-[1.05]',
tech: 'ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight',
studio: 'ui:text-5xl ui:lg:text-6xl ui:font-semibold ui:tracking-tight ui:leading-[1.02]',
corporate: 'ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight',
terminal: 'ui:text-[22px] ui:font-semibold 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'
classic: 'ui:text-lg ui:text-background/80',
saas: 'ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
tech: 'ui:text-lg ui:text-background/70 ui:font-mono',
studio: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
corporate: 'ui:text-base ui:text-muted-foreground ui:max-w-xl ui:mx-auto',
terminal: 'ui:text-sm ui:max-w-md ui:mx-auto ui:text-[#9da1ab]'
};
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'
classic: 'ui:px-8 ui:font-semibold',
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-primary/20',
tech: 'ui:rounded-none ui:px-8 ui:font-semibold',
studio: 'ui:rounded-md ui:px-5 ui:font-medium ui:shadow-lg ui:shadow-primary/25',
corporate: 'ui:rounded-none ui:px-8 ui:font-medium',
terminal:
'ui:rounded-full ui:px-4 ui:font-mono ui:text-[13px] ui:bg-[#0f1218] ui:text-[#e9eaed] ui:border ui:border-[#262a35] ui:hover:bg-[#14171f]'
};
const eyebrowClasses: Record<string, string> = {
minimal: '',
bold: '',
classic: '',
saas: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-1',
tech: 'ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-background/70 ui:mb-1',
studio: 'ui:text-sm ui:text-muted-foreground ui:inline-flex ui:items-center ui:gap-2 ui:mb-2',
corporate: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-1',
terminal: ''
};
const eyebrowLabels: Record<string, string | null> = {
minimal: null,
bold: null,
classic: null,
saas: 'Get started',
tech: '// get started',
studio: 'Ready when you are',
corporate: 'Ready when you are',
terminal: null
};
const minimalBackground = `
@@ -40,31 +84,63 @@
</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>
<EditableLandingSection sectionKey="callout">
<section
class={sectionClasses[variant]}
style={variant === 'minimal' ? `background: ${minimalBackground};` : undefined}
>
{#if variant === 'studio'}
<!-- soft radial glow at the bottom -->
<div
class="ui:pointer-events-none ui:absolute ui:left-1/2 ui:bottom-[-220px] ui:size-[800px] ui:-translate-x-1/2"
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 28%, transparent) 0%, transparent 60%); filter: blur(40px);"
aria-hidden="true"
></div>
{/if}
{#if variant === 'saas'}
<!-- soft accent ring; the parent .frame already provides the dot grid + side lines -->
<div
class="ui:pointer-events-none ui:absolute ui:top-1/2 ui:left-1/2 ui:size-[640px] ui:-translate-x-1/2 ui:-translate-y-1/2 ui:opacity-60"
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--primary) 14%, transparent) 0%, transparent 60%);"
aria-hidden="true"
></div>
{/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>
<div class="ui:relative ui:mx-auto ui:flex ui:max-w-3xl ui:flex-col ui:items-center ui:gap-6 ui:text-center">
{#if eyebrowLabels[variant]}
<BlurFade delay={0} once={true}>
{#if variant === 'studio'}
<p class={eyebrowClasses[variant]}>
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
{eyebrowLabels[variant]}
</p>
{:else}
<p class={eyebrowClasses[variant]}>{eyebrowLabels[variant]}</p>
{/if}
</BlurFade>
{/if}
<BlurFade delay={0.05} 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' || variant === 'tech' ? 'secondary' : 'default'}
>
{callout.action.label}
</Button>
</BlurFade>
</div>
</section>
</EditableLandingSection>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
@@ -10,8 +10,7 @@
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 { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from './landing-page-utils';
import { cn } from '../../tools';
let {
@@ -25,11 +24,9 @@
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
links,
footer
}: 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">
@@ -50,7 +47,7 @@
<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 courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const pricingData = {
cost: course.cost ?? 0,
discount: course.metadata?.discount ?? 0,
@@ -70,11 +67,11 @@
bannerAlt={course.title}
title={course.title}
description={course.description}
typeBadge={primaryTag
typeBadge={courseTypeMeta
? {
label: primaryTag.name,
icon: COURSE_TAG_ICON,
iconClass: primaryTag.color ? '' : 'custom ui:text-primary'
label: courseTypeMeta.label,
icon: courseTypeMeta.icon,
iconClass: courseTypeMeta.iconClass
}
: undefined}
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
@@ -146,9 +143,11 @@
</section>
</main>
<OrgLandingPageLinks {links} variant="classic" />
<OrgLandingPageEmbed {embed} variant="classic" />
<OrgLandingPageCallout {callout} variant="classic" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="classic" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
</div>
@@ -5,6 +5,7 @@
import { BlurFade } from '../../animation/blurfade';
import { BlurIn } from '../../animation/blurin';
import { DotPattern } from '../../animation/dot-pattern';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
@@ -13,42 +14,44 @@
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}
disabled={hero.primaryAction.disabled ?? false}
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}
<EditableLandingSection sectionKey="hero">
<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>
</BlurFade>
</div>
</section>
{/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}
disabled={hero.primaryAction.disabled ?? false}
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>
</EditableLandingSection>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
@@ -15,33 +16,35 @@
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
<EditableLandingSection sectionKey="navigation">
<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"
>
{/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}
{authAction.label}
</Button>
{/if}
</div>
</div>
</div>
</header>
</header>
</EditableLandingSection>
@@ -0,0 +1,119 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import CorporateNav from './corporate/nav.svelte';
import CorporateHero from './corporate/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer
}: 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>
<CorporateHero {orgName} {hero}>
{#snippet navigation()}
<CorporateNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</CorporateHero>
{#if courses.length > 0}
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
<div class="ui:max-w-[1120px] ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-10">
<div>
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
Catalog
</p>
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0 ui:max-w-xl">
Programs your teams are taking
</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-medium"
disabled={disableCourseLinks}
>
Browse catalog →
</Button>
{/if}
</div>
<div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
>
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
<BlurFade delay={0.06 * index} once={true}>
<a
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="ui:flex ui:flex-col ui:h-full ui:p-6 ui:gap-2 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:hover:bg-muted/30'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{#if courseTypeMeta}
<span
class="ui:text-[11px] ui:font-semibold ui:tracking-widest ui:uppercase ui:text-muted-foreground ui:mb-1"
>
{courseTypeMeta.label}
</span>
{/if}
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:leading-snug ui:m-0">
{course.title}
</h3>
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0">
{course.description}
</p>
<div class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-3 ui:border-t ui:border-border">
<span class="ui:text-sm ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</span>
<span class="ui:text-sm ui:text-foreground ui:underline ui:underline-offset-4" aria-hidden="true">
Enroll →
</span>
</div>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} variant="corporate" />
<OrgLandingPageEmbed {embed} variant="corporate" />
<OrgLandingPageCallout {callout} variant="corporate" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
</div>
@@ -0,0 +1,109 @@
<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 EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: OrgLandingPageProps['orgName'];
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
}
let { orgName, hero, navigation }: Props = $props();
const stats = $derived(hero.stats ?? []);
</script>
{@render navigation()}
<EditableLandingSection sectionKey="hero">
<section class="ui:px-6 ui:pt-12 ui:pb-24 ui:md:pt-16 ui:md:pb-28">
<div class="ui:max-w-[1120px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<div class="ui:flex ui:items-center ui:gap-4 ui:pb-5 ui:mb-10 ui:border-b ui:border-border">
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-foreground">
{orgName}
</span>
<span class="ui:h-px ui:flex-1 ui:bg-border" aria-hidden="true"></span>
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground">
Learning Report
</span>
</div>
</BlurFade>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-12 ui:gap-x-8 ui:gap-y-12">
<div class="ui:md:col-span-7">
<BlurFade delay={0.08} once={true}>
<h1
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-semibold ui:tracking-tight ui:leading-[1.05] ui:mb-6 ui:text-foreground"
>
{hero.heading}
</h1>
</BlurFade>
<BlurFade delay={0.18} once={true}>
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
{hero.subheading}
</p>
</BlurFade>
<BlurFade delay={0.28} once={true}>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
class="ui:rounded-none ui:px-6 ui:font-medium"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="corporate"
/>
{/if}
</div>
</BlurFade>
</div>
{#if stats.length > 0}
<aside class="ui:md:col-span-4 ui:md:col-start-9 ui:md:border-l ui:md:border-border ui:md:pl-8">
<BlurFade delay={0.32} once={true}>
<p
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-muted-foreground ui:mb-6"
>
At a glance
</p>
<dl class="ui:flex ui:flex-col">
{#each stats as stat, index (`${stat.label}-${index}`)}
<div
class="ui:flex ui:items-baseline ui:justify-between ui:gap-4 ui:py-4 ui:border-t ui:border-border ui:first:border-t-0"
>
<div class="ui:flex ui:items-baseline ui:gap-3 ui:min-w-0">
<span class="ui:text-[11px] ui:font-mono ui:text-muted-foreground ui:tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
<dt class="ui:text-sm ui:text-foreground ui:truncate">
{stat.label}
</dt>
</div>
<dd
class="ui:text-2xl ui:md:text-3xl ui:font-semibold ui:tracking-tight ui:tabular-nums ui:text-foreground ui:m-0"
>
{stat.value}
</dd>
</div>
{/each}
</dl>
</BlurFade>
</aside>
{/if}
</div>
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,56 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/95 ui:backdrop-blur-md ui:border-b ui:border-border">
<div class="ui:max-w-[1120px] ui:mx-auto ui:px-6">
<div class="ui:flex ui:items-center ui:justify-between ui:h-14">
<a
href="/"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
{/if}
{orgName}
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-7">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline"
>
{item.label}
</a>
{/each}
</nav>
{/if}
<div class="ui:flex ui:items-center ui:gap-3">
{#if authAction}
<Button href={authAction.href} size="sm" class="ui:rounded-none ui:px-4 ui:font-medium">
{authAction.label}
</Button>
{/if}
</div>
</div>
</div>
</header>
</EditableLandingSection>
@@ -0,0 +1,29 @@
import { getContext, setContext } from 'svelte';
import type { Component } from 'svelte';
/**
* Sections in the org landing page that the editor allows you to click in the preview
* to open. Matches the keys used by the editor's section list.
*/
export type LandingSectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'links' | 'footer';
export interface LandingPageEditContext {
/** Reactive read of the currently selected section (or null if none). */
selectedKey: () => LandingSectionKey | null;
/** Sets the selected section. Pass `null` to deselect. */
selectKey: (key: LandingSectionKey | null) => void;
/** Human-readable label for the section cap (typically translated). */
labelFor: (key: LandingSectionKey) => string;
/** Icon component rendered inside the section cap. */
iconFor: (key: LandingSectionKey) => Component;
}
const KEY = Symbol('orgLandingPageEdit');
export function setLandingPageEditContext(ctx: LandingPageEditContext): void {
setContext(KEY, ctx);
}
export function getLandingPageEditContext(): LandingPageEditContext | null {
return getContext<LandingPageEditContext | null>(KEY) ?? null;
}
@@ -0,0 +1,64 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { getLandingPageEditContext, type LandingSectionKey } from './edit-context';
import { cn } from '../../tools';
interface Props {
sectionKey: LandingSectionKey;
/** Extra classes applied to the wrapper (only used when edit context is active). */
class?: string;
children: Snippet;
}
let { sectionKey, class: className, children }: Props = $props();
const ctx = getLandingPageEditContext();
const selected = $derived(ctx?.selectedKey() === sectionKey);
const Icon = $derived(ctx?.iconFor(sectionKey));
function handleClick(event: MouseEvent) {
if (!ctx) return;
event.stopPropagation();
ctx.selectKey(sectionKey);
}
function handleKeydown(event: KeyboardEvent) {
if (!ctx) return;
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
event.stopPropagation();
ctx.selectKey(sectionKey);
}
</script>
{#if !ctx}
{@render children()}
{:else}
<div
data-landing-section={sectionKey}
role="button"
tabindex="0"
aria-pressed={selected}
onclick={handleClick}
onkeydown={handleKeydown}
class={cn(
'ui:relative ui:outline-none ui:cursor-pointer ui:transition-shadow',
selected
? 'ui:ring-2 ui:ring-primary ui:ring-inset'
: 'ui:hover:ring-2 ui:hover:ring-primary/40 ui:hover:ring-inset',
className
)}
>
{#if selected}
<div
class="ui:absolute ui:top-0 ui:left-0 ui:z-30 ui:-translate-y-full ui:flex ui:items-center ui:gap-1.5 ui:rounded-t-md ui:bg-primary ui:px-2 ui:py-1 ui:text-xs ui:font-medium ui:text-primary-foreground ui:shadow-sm ui:pointer-events-none"
>
{#if Icon}
<Icon class="ui:size-3.5" />
{/if}
<span>{ctx.labelFor(sectionKey)}</span>
</div>
{/if}
{@render children()}
</div>
{/if}
@@ -2,35 +2,95 @@
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
import SecondaryActionButton from './secondary-action-button.svelte';
import { BlurFade } from '../animation/blurfade';
import EditableLandingSection from './editable-section.svelte';
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>
<EditableLandingSection sectionKey="embed">
{#if variant === 'terminal'}
<section class="ui:py-24 ui:px-6" style="border-top: 1px solid #1c1f28; background: #06070a;">
<div class="ui:mx-auto ui:max-w-[1120px]">
<BlurFade delay={0} once={true}>
<p
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
style="color: var(--primary);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
></span>
Embed
</p>
</BlurFade>
<BlurFade delay={0.08} once={true}>
<h3
class="ui:text-[36px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-4"
style="color: #e9eaed; line-height: 1.1; letter-spacing: -0.025em;"
>
{embed.title}
</h3>
</BlurFade>
{#if embed.description?.trim()}
<BlurFade delay={0.16} once={true}>
<p class="ui:max-w-xl ui:m-0 ui:mb-6 ui:text-[15px]" style="color: #9da1ab;">
{embed.description}
</p>
</BlurFade>
{/if}
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
<BlurFade delay={0.22} once={true}>
<div class="ui:mb-8">
<SecondaryActionButton
href={embed.secondaryAction.href}
label={embed.secondaryAction.label}
{variant}
/>
</div>
</BlurFade>
{/if}
{#if embed.code}
<BlurFade delay={0.3} once={true}>
<div
class="ui:rounded-xl ui:overflow-hidden ui:max-w-[820px]"
style="border: 1px solid #262a35; background: #0f1218; box-shadow: 0 30px 90px -30px rgba(0,0,0,0.7);"
>
<div class="ui:p-5 ui:overflow-x-auto" style="color: #e9eaed;">
{@html embed.code}
</div>
</div>
</BlurFade>
{/if}
</div>
</section>
{:else}
<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}
</EditableLandingSection>
{/if}
@@ -0,0 +1,42 @@
<script lang="ts">
import type { FooterSocialPlatform } from './types';
import FacebookIcon from '@lucide/svelte/icons/facebook';
import GithubIcon from '@lucide/svelte/icons/github';
import GlobeIcon from '@lucide/svelte/icons/globe';
import InstagramIcon from '@lucide/svelte/icons/instagram';
import LinkedinIcon from '@lucide/svelte/icons/linkedin';
import YoutubeIcon from '@lucide/svelte/icons/youtube';
interface Props {
platform: FooterSocialPlatform;
class?: string;
}
let { platform, class: className = '' }: Props = $props();
</script>
{#if platform === 'instagram'}
<InstagramIcon class={className} aria-hidden="true" />
{:else if platform === 'linkedin'}
<LinkedinIcon class={className} aria-hidden="true" />
{:else if platform === 'facebook'}
<FacebookIcon class={className} aria-hidden="true" />
{:else if platform === 'youtube'}
<YoutubeIcon class={className} aria-hidden="true" />
{:else if platform === 'github'}
<GithubIcon class={className} aria-hidden="true" />
{:else if platform === 'website'}
<GlobeIcon class={className} aria-hidden="true" />
{:else if platform === 'x'}
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"
/>
</svg>
{:else if platform === 'tiktok'}
<svg class={className} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path
d="M19.59 6.69a4.83 4.83 0 0 1-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 0 1-5.2 1.74 2.89 2.89 0 0 1 2.31-4.64 2.93 2.93 0 0 1 .88.13V9.4a6.84 6.84 0 0 0-1-.05A6.33 6.33 0 0 0 5 20.1a6.34 6.34 0 0 0 10.86-4.43v-7a8.16 8.16 0 0 0 4.77 1.52v-3.4a4.85 4.85 0 0 1-1-.1z"
/>
</svg>
{/if}
@@ -0,0 +1,52 @@
import type { FooterSocialPlatform } from './types';
const KNOWN_PLATFORMS = new Set<FooterSocialPlatform>([
'instagram',
'x',
'linkedin',
'facebook',
'youtube',
'github',
'tiktok',
'website'
]);
/** Normalize persisted platform string (e.g. from JSON) to a known platform. */
export function resolveFooterSocialPlatform(value: unknown): FooterSocialPlatform {
if (typeof value === 'string' && KNOWN_PLATFORMS.has(value as FooterSocialPlatform)) {
return value as FooterSocialPlatform;
}
return 'website';
}
/**
* If a footer link label matches a known social brand, treat it as a social URL instead of a bottom link.
*/
export function labelMatchesSocialPlatform(label: string): FooterSocialPlatform | null {
const compact = label
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, '');
if (compact === 'facebook') return 'facebook';
if (compact === 'instagram') return 'instagram';
if (compact === 'twitter' || compact === 'x') return 'x';
if (compact === 'linkedin') return 'linkedin';
if (compact === 'youtube') return 'youtube';
if (compact === 'github' || compact === 'git') return 'github';
if (compact === 'tiktok') return 'tiktok';
return null;
}
export const FOOTER_SOCIAL_PLATFORMS = [
'instagram',
'x',
'linkedin',
'facebook',
'youtube',
'github',
'tiktok',
'website'
] as const satisfies readonly FooterSocialPlatform[];
@@ -1,14 +1,38 @@
export { default as MinimalLandingPage } from './minimal.svelte';
export { default as BoldLandingPage } from './bold.svelte';
export { default as ClassicLandingPage } from './classic.svelte';
export { default as SaasLandingPage } from './saas.svelte';
export { default as TechLandingPage } from './tech.svelte';
export { default as StudioLandingPage } from './studio.svelte';
export { default as CorporateLandingPage } from './corporate.svelte';
export { default as TerminalLandingPage } from './terminal.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 SaasLandingNav } from './saas/nav.svelte';
export { default as SaasLandingHero } from './saas/hero.svelte';
export { default as TechLandingNav } from './tech/nav.svelte';
export { default as TechLandingHero } from './tech/hero.svelte';
export { default as StudioLandingNav } from './studio/nav.svelte';
export { default as StudioLandingHero } from './studio/hero.svelte';
export { default as CorporateLandingNav } from './corporate/nav.svelte';
export { default as CorporateLandingHero } from './corporate/hero.svelte';
export { default as TerminalLandingNav } from './terminal/nav.svelte';
export { default as TerminalLandingHero } from './terminal/hero.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte';
export { default as OrgLandingPageLinks } from './links.svelte';
export { default as OrgLandingPageFooter } from './landing-page-footer.svelte';
export { default as SecondaryActionButton } from './secondary-action-button.svelte';
export { default as EditableLandingSection } from './editable-section.svelte';
export {
setLandingPageEditContext,
getLandingPageEditContext,
type LandingSectionKey,
type LandingPageEditContext
} from './edit-context';
export * from './landing-page-link-icons';
export * from './types';
@@ -1,52 +1,172 @@
<script lang="ts">
import type { OrgLandingPageProps, OrgLandingPageTheme } from './types';
import type { OrgLandingPageFooterConfig, OrgLandingPageTheme } from './types';
import { getFooterTokens } from './landing-page-footer.tokens';
import FooterSocialIcon from './footer-social-icon.svelte';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { BlurFade } from '../animation/blurfade';
import EditableLandingSection from './editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
footerLinks: OrgLandingPageProps['footerLinks'];
footerText: string;
footer: OrgLandingPageFooterConfig;
variant: OrgLandingPageTheme;
}
let { orgName, logoUrl, footerLinks, footerText, variant }: Props = $props();
let { orgName, logoUrl, footer, variant }: Props = $props();
const tokens = $derived(getFooterTokens(variant));
const bottom = $derived(footer.bottom);
const bottomLinks = $derived(bottom?.links ?? []);
const bottomText = $derived((bottom?.text ?? '').trim());
function isGridMode(config: OrgLandingPageFooterConfig): boolean {
const tagline = (config.brand.tagline ?? '').trim();
const copyright = (config.brand.copyright ?? '').trim();
return config.columns.length > 0 || config.brand.socials.length > 0 || Boolean(tagline) || Boolean(copyright);
}
const showGrid = $derived(isGridMode(footer));
const showBottomRowInGrid = $derived(bottomText.length > 0 || bottomLinks.length > 0);
function hasCta(column: OrgLandingPageFooterConfig['columns'][number]): boolean {
const cta = column.cta;
if (!cta) {
return false;
}
return Boolean(cta.label.trim() && cta.href.trim());
}
</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" />
<EditableLandingSection sectionKey="footer">
<footer class={tokens.root}>
{#if variant === 'terminal'}
<div
class="ui:pointer-events-none ui:absolute ui:inset-x-[-10%] ui:bottom-[-50%] ui:h-[380px]"
style="
background:
radial-gradient(ellipse 60% 80% at 30% 50%, color-mix(in oklab, #f59e0b 35%, transparent) 0%, transparent 60%),
radial-gradient(ellipse 50% 60% at 70% 60%, color-mix(in oklab, #ef4444 30%, transparent) 0%, transparent 60%),
radial-gradient(ellipse 40% 50% at 50% 80%, color-mix(in oklab, #c026d3 22%, transparent) 0%, transparent 60%);
filter: blur(40px);
"
aria-hidden="true"
></div>
{/if}
<div class="ui:relative {tokens.container}">
{#if showGrid}
{#snippet gridInner()}
<div
class="ui:grid ui:grid-cols-1 ui:gap-y-10 ui:gap-x-8 ui:sm:grid-cols-2 ui:md:gap-x-12 ui:md:gap-y-12 ui:md:grid-cols-[minmax(0,1.4fr)_repeat(auto-fit,minmax(140px,1fr))]"
>
<div class="ui:sm:col-span-2 ui:md:col-span-1 ui:min-w-0">
<a href="/" class={tokens.brandName}>
{#if logoUrl}
<img src={logoUrl} alt="" class={tokens.logoImg} />
{/if}
<span class="ui:break-words">{orgName}</span>
</a>
{#if footer.brand.tagline?.trim()}
<p class={tokens.brandTagline}>{footer.brand.tagline}</p>
{/if}
{#if footer.brand.socials.length > 0}
<ul class={tokens.socialWrap}>
{#each footer.brand.socials as social (social.href + social.platform)}
<li class="ui:list-none">
<a
href={social.href}
class={tokens.socialIcon}
target="_blank"
rel="noreferrer noopener"
aria-label={social.platform}
>
<FooterSocialIcon platform={social.platform} class="ui:size-4 ui:md:size-[1.125rem]" />
</a>
</li>
{/each}
</ul>
{/if}
{#if footer.brand.copyright?.trim()}
<p class="{tokens.brandTagline} ui:mt-3">{footer.brand.copyright}</p>
{/if}
</div>
{#each footer.columns as column (column.id)}
<div class="ui:min-w-0">
{#if column.heading.trim()}
<h3 class={tokens.columnHeading}>{column.heading}</h3>
{/if}
<ul class="ui:list-none ui:m-0 ui:p-0 ui:space-y-1">
{#each column.links as link (link.id)}
<li>
<a href={link.href} class={tokens.columnLink}>{link.label}</a>
</li>
{/each}
</ul>
{#if hasCta(column)}
<a href={column.cta!.href} class={tokens.columnCta}>
{column.cta!.label}
<ArrowRightIcon class="ui:size-4 ui:shrink-0" aria-hidden="true" />
</a>
{/if}
</div>
{/each}
</div>
{/snippet}
{#if variant === 'minimal' || variant === 'bold' || variant === 'saas' || variant === 'tech' || variant === 'studio' || variant === 'corporate' || variant === 'terminal'}
<BlurFade delay={0} once={true}>
{#snippet children()}
{@render gridInner()}
{/snippet}
</BlurFade>
{:else}
{@render gridInner()}
{/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>
{#if showBottomRowInGrid}
{#if variant === 'classic' && tokens.bottomHairline}
<div class={tokens.bottomHairline} aria-hidden="true"></div>
{/if}
<div class={tokens.bottomRow}>
{#if bottomText}
<p class={tokens.bottomText}>{bottomText}</p>
{/if}
{#if bottomLinks.length > 0}
<div class={tokens.bottomLinksWrap}>
{#each bottomLinks as link (link.id)}
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
{/each}
</div>
{/if}
</div>
{/if}
{:else}
<div class={tokens.compactRow}>
<a href="/" class={tokens.brandName}>
{#if logoUrl}
<img src={logoUrl} alt="" class={tokens.logoImg} />
{/if}
<span class="ui:break-words">{orgName}</span>
</a>
{#if bottomLinks.length > 0}
<div
class="ui:flex ui:flex-wrap ui:justify-start ui:gap-6 ui:md:justify-center ui:md:gap-8 ui:w-full ui:md:w-auto"
>
{#each bottomLinks as link (link.id)}
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
{/each}
</div>
{/if}
{#if bottomText}
<p class="{tokens.bottomText} ui:w-full ui:md:w-auto ui:text-left ui:md:text-right">{bottomText}</p>
{/if}
</div>
{/if}
</div>
</footer>
{/if}
</EditableLandingSection>
@@ -0,0 +1,239 @@
import type { OrgLandingPageTheme } from './types';
/** Class strings per footer region; markup reads these so themes stay consistent with nav/hero tokens. */
export type FooterRegionTokens = {
root: string;
container: string;
compactRow: string;
bottomRow: string;
bottomHairline?: string;
brandName: string;
brandTagline: string;
columnHeading: string;
columnLink: string;
columnCta: string;
socialWrap: string;
socialIcon: string;
bottomText: string;
bottomLinksWrap: string;
bottomLink: string;
logoImg: string;
};
export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionTokens {
if (variant === 'minimal') {
return {
root: 'ui:border-t ui:border-border/50 ui:py-12 ui:px-4 ui:sm:px-6 ui:md:px-8 ui:md:py-16',
container: 'ui:max-w-[1200px] ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-border/50 ui:text-sm ui:text-muted-foreground',
brandName:
'ui:font-semibold ui:text-xl ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-2',
columnHeading: 'ui:text-sm ui:font-semibold ui:text-foreground ui:mb-4',
columnLink:
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-semibold ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
};
}
if (variant === 'tech') {
return {
root: 'ui:bg-foreground ui:text-background ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20',
container: 'ui:max-w-7xl ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/55 ui:font-mono',
brandName:
'ui:font-bold ui:text-xl ui:tracking-tight ui:text-background ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-3 ui:max-w-[260px]',
columnHeading:
'ui:text-[11px] ui:font-mono ui:font-semibold ui:uppercase ui:tracking-widest ui:text-background/60 ui:mb-4',
columnLink:
'ui:block ui:text-sm ui:text-background/75 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:border ui:border-background/15 ui:bg-background/5 ui:text-background/75 ui:hover:text-background ui:hover:bg-background/10 ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:font-mono ui:text-background/65 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
};
}
if (variant === 'studio') {
return {
root: 'ui:bg-background ui:text-foreground ui:border-t ui:border-border ui:py-16 ui:px-4 ui:sm:px-6 ui:md:py-20',
container: 'ui:max-w-[1080px] ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
brandName:
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
columnHeading: 'ui:text-xs ui:font-medium ui:text-muted-foreground ui:mb-3',
columnLink:
'ui:block ui:text-[13px] ui:text-foreground ui:hover:text-muted-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-medium ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:size-7 ui:rounded-md ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
};
}
if (variant === 'terminal') {
return {
root: 'ui:relative ui:overflow-hidden ui:pt-16 ui:pb-0 ui:px-4 ui:sm:px-6 ui:md:pt-20 ui:bg-[#06070a]',
container: 'ui:max-w-[1120px] ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-[#9da1ab]',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-14 ui:pt-6 ui:pb-6 ui:border-t ui:border-[#1c1f28] ui:text-xs ui:text-[#61656f]',
brandName:
'ui:font-semibold ui:text-base ui:text-[#e9eaed] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[#9da1ab] ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
columnHeading: 'ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:text-[#61656f] ui:mb-4',
columnLink:
'ui:block ui:text-[13.5px] ui:text-[#9da1ab] ui:hover:text-[#e9eaed] ui:transition-colors ui:break-words ui:py-1 ui:md:py-0 ui:no-underline',
columnCta:
'ui:text-sm ui:font-medium ui:text-[#e9eaed] ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:text-[#9da1ab] ui:hover:text-[#e9eaed] ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:text-[#61656f] ui:hover:text-[#e9eaed] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0 ui:no-underline',
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
};
}
if (variant === 'corporate') {
return {
root: 'ui:bg-background ui:border-t ui:border-border ui:py-12 ui:px-4 ui:sm:px-6 ui:md:py-16',
container: 'ui:max-w-[1120px] ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-12 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
brandName:
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed',
columnHeading: 'ui:text-xs ui:font-semibold ui:tracking-wider ui:text-foreground ui:mb-3 ui:uppercase',
columnLink:
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-medium ui:text-foreground ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline ui:underline ui:underline-offset-4',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-3 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
};
}
if (variant === 'saas') {
return {
root: 'ui:bg-background ui:border-t ui:border-border ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16',
container: 'ui:max-w-[1180px] ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm ui:text-muted-foreground',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:pt-6 ui:border-t ui:border-border ui:text-xs ui:text-muted-foreground',
brandName:
'ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-muted-foreground ui:break-words ui:mt-3 ui:max-w-[260px]',
columnHeading: 'ui:text-sm ui:font-bold ui:text-foreground ui:mb-3',
columnLink:
'ui:block ui:text-sm ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-semibold ui:text-primary ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-3 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-4 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:bg-muted/40 ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-5 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:text-muted-foreground ui:hover:text-foreground ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-6 ui:md:h-7 ui:w-auto'
};
}
if (variant === 'bold') {
return {
root: 'ui:bg-foreground ui:text-background/60 ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-20',
container: 'ui:max-w-7xl ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:border-t ui:border-background/15 ui:text-xs ui:text-background/50',
brandName:
'ui:font-black ui:text-2xl ui:tracking-tighter ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-background/60 ui:break-words ui:mt-2',
columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-background ui:mb-4',
columnLink:
'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-bold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-xs ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto ui:grayscale ui:brightness-200'
};
}
return {
root: 'ui:bg-foreground ui:text-background ui:py-12 ui:px-4 ui:sm:px-6 ui:lg:px-8 ui:md:py-16',
container: 'ui:max-w-7xl ui:mx-auto',
compactRow:
'ui:flex ui:flex-col ui:gap-3 ui:items-start ui:md:flex-row ui:md:items-center ui:md:justify-between ui:text-sm',
bottomRow:
'ui:flex ui:flex-col ui:gap-3 ui:sm:flex-row ui:sm:items-center ui:sm:justify-between ui:mt-10 ui:md:mt-12 ui:pt-6 ui:text-sm ui:text-background/70',
bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-background/30 ui:mx-auto ui:mb-6',
brandName:
'ui:text-xl ui:font-bold ui:tracking-wide ui:text-background ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-background/70 ui:break-words ui:mt-2',
columnHeading: 'ui:text-sm ui:font-semibold ui:text-background ui:mb-4',
columnLink:
'ui:block ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-0',
columnCta:
'ui:text-sm ui:font-semibold ui:text-background ui:inline-flex ui:items-center ui:gap-1.5 ui:mt-4 ui:no-underline hover:ui:underline',
socialWrap: 'ui:flex ui:flex-wrap ui:gap-2 ui:mt-3 ui:list-none ui:m-0 ui:p-0',
socialIcon:
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-background/70 ui:hover:text-background ui:transition-colors ui:no-underline',
bottomText: 'ui:break-words',
bottomLinksWrap: 'ui:flex ui:flex-wrap ui:gap-x-6 ui:gap-y-2 ui:justify-start ui:sm:justify-end',
bottomLink:
'ui:text-sm ui:font-medium ui:text-background/70 ui:hover:text-background ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0',
logoImg: 'ui:h-7 ui:md:h-8 ui:w-auto'
};
}
@@ -0,0 +1,38 @@
import type { Component, ComponentProps } from 'svelte';
import type { LandingPageLinkIcon } from './types';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import CalendarIcon from '@lucide/svelte/icons/calendar';
import HelpCircleIcon from '@lucide/svelte/icons/help-circle';
import LifeBuoyIcon from '@lucide/svelte/icons/life-buoy';
import MailIcon from '@lucide/svelte/icons/mail';
import MessageCircleIcon from '@lucide/svelte/icons/message-circle';
import NewspaperIcon from '@lucide/svelte/icons/newspaper';
import RocketIcon from '@lucide/svelte/icons/rocket';
import UsersIcon from '@lucide/svelte/icons/users';
import VideoIcon from '@lucide/svelte/icons/video';
export type LinkIconComponent = Component<ComponentProps<typeof HelpCircleIcon>>;
/** Every key in `LandingPageLinkIcon` must map to a Lucide icon component. */
export const landingPageLinkIconMap: Record<LandingPageLinkIcon, LinkIconComponent> = {
'help-circle': HelpCircleIcon,
'life-buoy': LifeBuoyIcon,
'book-open': BookOpenIcon,
video: VideoIcon,
users: UsersIcon,
'message-circle': MessageCircleIcon,
newspaper: NewspaperIcon,
rocket: RocketIcon,
calendar: CalendarIcon,
mail: MailIcon
};
const FALLBACK_ICON: LandingPageLinkIcon = 'help-circle';
export function resolveLandingPageLinkIcon(value: unknown): LandingPageLinkIcon {
if (typeof value === 'string' && value in landingPageLinkIconMap) {
return value as LandingPageLinkIcon;
}
return FALLBACK_ICON;
}
@@ -1,9 +1,64 @@
import type { Component } from 'svelte';
import CircleDotIcon from '@lucide/svelte/icons/circle-dot';
import GlobeIcon from '@lucide/svelte/icons/globe';
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check';
import UserIcon from '@lucide/svelte/icons/user';
import type { CourseItem } from './types';
export function getPrimaryCourseTag(course: CourseItem) {
return course.tags?.[0];
}
/** Label + icon for course `type` (COURSE_TYPE enum) on org landing cards. */
export function getCourseTypeLandingMeta(
course: CourseItem
): { label: string; icon: Component; iconClass?: string } | undefined {
const raw = course.type?.trim();
if (!raw) return undefined;
const known: Record<string, { label: string; icon: Component; iconClass?: string }> = {
LIVE_CLASS: {
label: 'Live class',
icon: CircleDotIcon,
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-red-700'
},
SELF_PACED: {
label: 'Self-paced',
icon: UserIcon,
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-primary'
},
COMPLIANCE: {
label: 'Compliance',
icon: ShieldCheckIcon,
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-emerald-700'
},
PUBLIC: {
label: 'Public',
icon: GlobeIcon,
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-primary'
}
};
if (known[raw]) {
return known[raw];
}
const label = raw
.toLowerCase()
.split('_')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
return {
label: label || raw,
icon: GraduationCapIcon,
iconClass: 'custom ui:size-3.5 ui:shrink-0 ui:text-muted-foreground'
};
}
/** Plain copy for org landing templates (no dashboard i18n). */
export function formatExerciseCountLabel(count: number) {
if (count === 1) return '1 exercise';
@@ -0,0 +1,457 @@
<script lang="ts">
import type { LandingPageLinks, OrgLandingPageTheme } from './types';
import { landingPageLinkIconMap } from './landing-page-link-icons';
import { BlurFade } from '../animation/blurfade';
import { BorderBeam } from '../animation/border-beam';
import { DotPattern } from '../animation/dot-pattern';
import * as Card from '../../base/card';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import EditableLandingSection from './editable-section.svelte';
let { links, variant = 'minimal' }: { links?: LandingPageLinks; variant?: OrgLandingPageTheme } = $props();
function linkDisplayHint(href: string) {
const trimmed = href.trim();
if (!trimmed) return '';
try {
const withProtocol = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`;
const url = new URL(withProtocol);
return url.hostname.replace(/^www\./, '');
} catch {
return trimmed.length > 48 ? `${trimmed.slice(0, 45)}…` : trimmed;
}
}
</script>
{#if links && links.cards.length > 0}
<EditableLandingSection sectionKey="links">
{#if variant === 'minimal'}
<section class="ui:py-20 ui:px-4">
<div class="ui:max-w-[1200px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-2xl ui:font-semibold {links.description?.trim() ? 'ui:mb-3' : 'ui:mb-10'}">
{links.heading}
</h2>
</BlurFade>
{#if links.description?.trim()}
<BlurFade delay={0.12} once={true}>
<p class="ui:text-base ui:text-muted-foreground ui:max-w-2xl ui:mb-10">
{links.description}
</p>
</BlurFade>
{/if}
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-6">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.1 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<Card.Root
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 ui:transition-colors ui:hover:border-border"
>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1 ui:h-full">
<IconComponent class="ui:size-6 ui:text-foreground ui:mb-6" aria-hidden="true" />
<Card.Title class="ui:text-xl ui:font-normal ui:mb-3">{card.title}</Card.Title>
<Card.Description
class="ui:text-base ui:leading-relaxed ui:text-muted-foreground ui:line-clamp-3 ui:mb-6"
>
{card.description}
</Card.Description>
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-sm ui:text-muted-foreground ui:mt-auto"
aria-hidden="true"
>
<span class="ui:truncate">{linkDisplayHint(card.href)}</span>
<ArrowRightIcon
class="ui:size-4 ui:shrink-0 ui:transition-transform ui:group-hover:translate-x-0.5"
/>
</div>
</Card.Content>
</Card.Root>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'tech'}
<section class="ui:relative ui:py-24 ui:px-6 ui:bg-primary ui:text-primary-foreground ui:overflow-hidden">
<DotPattern class="ui:opacity-[0.08]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:mb-3">{links.heading}</h2>
</BlurFade>
{#if links.description?.trim()}
<BlurFade delay={0.12} once={true}>
<p class="ui:text-lg ui:text-primary-foreground/80 ui:max-w-2xl ui:mb-14 ui:font-mono">
{links.description}
</p>
</BlurFade>
{:else}
<div class="ui:mb-14" aria-hidden="true"></div>
{/if}
<div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border ui:border-primary-foreground/20"
>
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.08 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:p-8 ui:border-r ui:border-b ui:border-primary-foreground/15 ui:transition-colors ui:hover:bg-primary-foreground/5 ui:no-underline ui:text-primary-foreground"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-primary-foreground/15 ui:text-primary-foreground ui:font-mono ui:font-bold ui:mb-5"
>
<IconComponent class="ui:size-4 custom" aria-hidden="true" />
</div>
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:mb-2">{card.title}</h3>
<p class="ui:text-sm ui:text-primary-foreground/70 ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'studio'}
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border ui:bg-background">
<div class="ui:max-w-[1080px] ui:mx-auto">
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:mb-12 ui:items-end">
<BlurFade delay={0} once={true}>
<div>
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
Resources
</p>
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">{links.heading}</h2>
</div>
</BlurFade>
{#if links.description?.trim()}
<BlurFade delay={0.1} once={true}>
<p class="ui:text-base ui:text-muted-foreground ui:leading-relaxed ui:m-0">
{links.description}
</p>
</BlurFade>
{/if}
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-4 ui:gap-3">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.06 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:p-6 ui:bg-card ui:border ui:border-border ui:rounded-xl ui:transition-colors ui:hover:border-foreground/30 ui:hover:bg-muted/30 ui:no-underline"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-lg ui:bg-muted ui:border ui:border-border ui:text-primary ui:mb-3"
>
<IconComponent class="ui:size-4" aria-hidden="true" />
</div>
<h3 class="ui:text-sm ui:font-medium ui:tracking-tight ui:mb-1.5 ui:text-foreground">{card.title}</h3>
<p class="ui:text-[13px] ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'terminal'}
{@const cardCount = links.cards.length}
{@const lgColsClass =
cardCount >= 4
? 'ui:lg:grid-cols-4'
: cardCount === 3
? 'ui:lg:grid-cols-3'
: cardCount === 2
? 'ui:lg:grid-cols-2'
: 'ui:lg:grid-cols-1'}
{@const mdColsClass = cardCount === 1 ? 'ui:md:grid-cols-1' : 'ui:md:grid-cols-2'}
<section
class="ui:py-24 ui:px-6"
style="border-top: 1px solid #1c1f28; background:
radial-gradient(ellipse 60% 100% at 50% 0%, color-mix(in oklab, var(--primary) 6%, transparent) 0%, transparent 60%),
#06070a;"
>
<div class="ui:max-w-[1120px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<p
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
style="color: var(--primary);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
></span>
Resources
</p>
</BlurFade>
<BlurFade delay={0.08} once={true}>
<h2
class="ui:text-[40px] ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10"
style="color: #e9eaed; letter-spacing: -0.025em; line-height: 1.08;"
>
{links.heading}
</h2>
</BlurFade>
<div
class="ui:grid ui:grid-cols-1 {mdColsClass} {lgColsClass} ui:overflow-hidden ui:rounded-[14px] ui:gap-[1px]"
style="border: 1px solid #1c1f28; background: #1c1f28;"
>
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.06 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:flex ui:flex-col ui:gap-3 ui:p-7 ui:no-underline ui:h-full ui:transition-colors"
style="background: #0f1218; color: #e9eaed;"
onmouseenter={(e) => (e.currentTarget.style.background = '#14171f')}
onmouseleave={(e) => (e.currentTarget.style.background = '#0f1218')}
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:rounded-lg"
style="border: 1px solid #262a35; background: #14171f; color: var(--primary);"
>
<IconComponent class="ui:size-4" aria-hidden="true" />
</div>
<h3 class="ui:text-sm ui:font-semibold ui:m-0" style="color: #e9eaed;">
{card.title}
</h3>
<p class="ui:text-[13px] ui:m-0 ui:line-clamp-3" style="color: #9da1ab; line-height: 1.5;">
{card.description}
</p>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'corporate'}
<section class="ui:py-20 ui:px-6 ui:bg-muted/30 ui:border-t ui:border-b ui:border-border">
<div class="ui:max-w-[1120px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-foreground ui:mb-3">
Resources
</p>
</BlurFade>
<BlurFade delay={0.1} once={true}>
<h2 class="ui:text-3xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10">{links.heading}</h2>
</BlurFade>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-border">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.06 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:flex ui:gap-5 ui:items-start ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-9 ui:bg-foreground ui:text-background ui:flex-shrink-0"
>
<IconComponent class="ui:size-4" aria-hidden="true" />
</div>
<div class="ui:min-w-0">
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:mb-1.5 ui:m-0">{card.title}</h3>
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</div>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'saas'}
<section class="ui:py-20 ui:px-6 ui:bg-muted/40">
<div class="ui:max-w-[1180px] ui:mx-auto">
<div class="ui:text-center ui:max-w-xl ui:mx-auto ui:mb-12">
<BlurFade delay={0} once={true}>
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">
Resources
</p>
</BlurFade>
<BlurFade delay={0.1} once={true}>
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight ui:m-0">
{links.heading}
</h2>
</BlurFade>
{#if links.description?.trim()}
<BlurFade delay={0.18} once={true}>
<p class="ui:text-base ui:text-muted-foreground ui:mt-3 ui:leading-relaxed">
{links.description}
</p>
</BlurFade>
{/if}
</div>
<div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[1px] ui:bg-border ui:border ui:border-border"
>
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<BlurFade delay={0.08 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:bg-background ui:p-7 ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:transition-colors ui:hover:bg-muted/40 ui:no-underline ui:h-full"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-10 ui:rounded-xl ui:bg-primary/10 ui:text-primary ui:mb-1"
>
<IconComponent class="ui:size-5" aria-hidden="true" />
</div>
<h3 class="ui:text-base ui:font-bold ui:tracking-tight ui:m-0">{card.title}</h3>
<p class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else if variant === 'bold'}
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-background ui:overflow-hidden">
<DotPattern class="ui:opacity-[0.10]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight ui:mb-3">{links.heading}</h2>
</BlurFade>
{#if links.description?.trim()}
<BlurFade delay={0.12} once={true}>
<p class="ui:text-lg ui:text-muted-foreground ui:max-w-2xl ui:mb-12">{links.description}</p>
</BlurFade>
{:else}
<div class="ui:mb-12" aria-hidden="true"></div>
{/if}
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
{@const visitLabel = links.boldVisitLabel?.trim()}
<BlurFade delay={0.1 * index} once={true}>
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline"
aria-label={`${card.title}${visitLabel ? ` — ${visitLabel}` : ''} (opens in new tab)`}
>
<Card.Root
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:bg-background ui:border-border/50 ui:p-8 ui:h-full ui:flex ui:flex-col ui:transition-all ui:duration-300 ui:hover:shadow-xl ui:hover:shadow-primary/5"
>
<BorderBeam
size={120}
duration={8}
colorFrom="var(--primary)"
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
/>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-12 ui:rounded-2xl ui:bg-primary/10 ui:text-primary ui:mb-6"
>
<IconComponent class="ui:size-6" aria-hidden="true" />
</div>
<Card.Title class="ui:text-2xl ui:font-bold ui:tracking-tight ui:mb-3">{card.title}</Card.Title>
<Card.Description class="ui:text-base ui:text-muted-foreground ui:line-clamp-3 ui:mb-6">
{card.description}
</Card.Description>
<div class="ui:mt-auto">
<span
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:text-sm ui:font-bold ui:text-foreground ui:bg-muted ui:px-3 ui:py-1.5 ui:rounded-lg ui:transition-colors ui:group-hover:bg-primary ui:group-hover:text-primary-foreground"
aria-hidden="true"
>
{#if visitLabel}
{visitLabel}
{/if}
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</Card.Root>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{:else}
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
<div class="ui:text-center">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">{links.heading}</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>
{#if links.description?.trim()}
<BlurFade delay={0.22} once={true}>
<p class="ui:mt-6 ui:text-center ui:text-muted-foreground ui:max-w-2xl ui:mx-auto">
{links.description}
</p>
</BlurFade>
{/if}
</div>
<div
class="ui:mt-10 ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center"
>
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
{@const learnMoreLabel = links.classicLearnMoreLabel?.trim()}
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:max-w-md">
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline"
aria-label={`${card.title}${learnMoreLabel ? ` — ${learnMoreLabel}` : ''} (opens in new tab)`}
>
<Card.Root
class="ui:w-full ui:rounded-xl ui:border-border ui:bg-background ui:p-6 ui:h-full ui:flex ui:flex-col ui:hover:shadow-md ui:transition-shadow"
>
<div class="ui:flex ui:items-center ui:justify-center ui:size-12 ui:rounded-full ui:bg-muted ui:mb-4">
<IconComponent class="ui:size-5 ui:text-foreground" aria-hidden="true" />
</div>
<Card.Title class="ui:text-xl ui:font-semibold ui:mb-2">{card.title}</Card.Title>
<Card.Description class="ui:text-sm ui:text-muted-foreground ui:line-clamp-3 ui:mb-4">
{card.description}
</Card.Description>
<div
class="ui:text-sm ui:font-medium ui:text-foreground ui:border-t ui:border-border/60 ui:pt-3 ui:mt-auto ui:flex ui:items-center ui:gap-1.5 ui:group-hover:text-primary ui:transition-colors"
aria-hidden="true"
>
{#if learnMoreLabel}
<span>{learnMoreLabel}</span>
{/if}
<ArrowRightIcon class="ui:size-4" />
</div>
</Card.Root>
</a>
</BlurFade>
{/each}
</div>
</section>
{/if}
</EditableLandingSection>
{/if}
@@ -1,6 +1,7 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import MinimalNav from './minimal/nav.svelte';
@@ -10,7 +11,7 @@
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';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
@@ -23,8 +24,8 @@
disableCourseLinks = false,
embed,
callout,
footerLinks,
footerText
links,
footer
}: OrgLandingPageProps = $props();
function formatCurrency(cost?: number, currency = 'USD') {
@@ -46,6 +47,7 @@
<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 courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<a
@@ -76,6 +78,13 @@
<span>{course.duration}</span>
</div>
{/if}
{#if courseTypeMeta}
{@const TypeIcon = courseTypeMeta.icon}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<TypeIcon class={courseTypeMeta.iconClass} />
<span>{courseTypeMeta.label}</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" />
@@ -116,9 +125,11 @@
</section>
</main>
<OrgLandingPageLinks {links} variant="minimal" />
<OrgLandingPageEmbed {embed} variant="minimal" />
<OrgLandingPageCallout {callout} variant="minimal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footerLinks} {footerText} variant="minimal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
</div>
@@ -6,6 +6,7 @@
import { BlurFade } from '../../animation/blurfade';
import { DotPattern } from '../../animation/dot-pattern';
import { ShinnyText } from '../../animation/shinny-text';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
@@ -29,45 +30,47 @@
`;
</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>
<EditableLandingSection sectionKey="hero">
<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}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-full"
<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.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="minimal"
/>
{/if}
</div>
</BlurFade>
</div>
</section>
{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}
disabled={hero.primaryAction.disabled ?? false}
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>
</EditableLandingSection>
@@ -1,6 +1,7 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
@@ -23,28 +24,30 @@
`;
</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>
<EditableLandingSection sectionKey="navigation">
<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>
</EditableLandingSection>
@@ -0,0 +1,281 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import SaasNav from './saas/nav.svelte';
import SaasHero from './saas/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer
}: 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="saas-root ui:min-h-screen ui:bg-background ui:text-foreground ui:font-sans">
<div class="frame">
<SaasHero {hero}>
{#snippet navigation()}
<SaasNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</SaasHero>
{#if courses.length > 0}
<div class="plus-row"></div>
<section class="ui:py-20 ui:px-6">
<div class="ui:max-w-[1180px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-12">
<div>
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">Catalog</p>
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">Featured programs.</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:self-start ui:md:self-auto"
disabled={disableCourseLinks}
>
Browse catalog
</Button>
{/if}
</div>
<div class="course-grid ui:relative">
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.06 * index} once={true} class="course-card-wrap">
<a
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="course-card ui:flex ui:flex-col ui:h-full ui:no-underline ui:bg-background ui:transition-colors {disableCourseLinks
? 'ui:cursor-default'
: 'ui:hover:bg-muted/40'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{#if course.logo}
<div
class="ui:h-40 ui:w-full ui:bg-cover ui:bg-center ui:relative"
style={`background-image: url(${course.logo});`}
>
{#if courseTypeMeta}
<span
class="ui:absolute ui:top-3 ui:left-3 ui:bg-background/95 ui:text-foreground ui:text-[11px] ui:font-semibold ui:px-2.5 ui:py-1 ui:rounded-full"
>
{courseTypeMeta.label}
</span>
{/if}
</div>
{/if}
<div class="ui:p-5 ui:flex ui:flex-col ui:flex-1 ui:gap-2">
<h3 class="ui:text-lg ui:font-bold ui:tracking-tight ui:leading-snug ui:m-0">
{course.title}
</h3>
<p class="ui:text-sm ui:text-muted-foreground ui:line-clamp-2 ui:leading-relaxed ui:m-0 ui:flex-1">
{course.description}
</p>
<div
class="ui:flex ui:items-center ui:gap-2 ui:flex-wrap ui:text-xs ui:text-muted-foreground ui:mt-2"
>
{#if course.duration}
<span class="ui:inline-flex ui:items-center ui:gap-1">
<ClockIcon class="ui:size-3.5" />
{course.duration}
</span>
{/if}
{#if primaryTag}
<span class="ui:inline-flex ui:items-center ui:gap-1">
<TagIcon class="ui:size-3.5" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:size-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
{primaryTag.name}
</span>
{/if}
</div>
<div
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-2 ui:border-t ui:border-border/60"
>
<span class="ui:text-sm ui:font-bold">
{course.price || formatCurrency(course.cost, course.currency)}
</span>
<span
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-xs ui:font-semibold ui:text-primary"
aria-hidden="true"
>
Enroll
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</div>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{/if}
{#if links && links.cards.length > 0}
<div class="plus-row"></div>
<OrgLandingPageLinks {links} variant="saas" />
{/if}
{#if embed}
<div class="plus-row"></div>
<OrgLandingPageEmbed {embed} variant="saas" />
{/if}
{#if callout}
<div class="plus-row"></div>
<OrgLandingPageCallout {callout} variant="saas" />
{/if}
</div>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="saas" />
</div>
<style>
/* ============ FRAME (vertical lines + dotted bg) ============ */
.saas-root :global(.frame) {
position: relative;
max-width: 1180px;
margin: 0 auto;
width: 100%;
background-image: radial-gradient(
circle at center,
color-mix(in oklab, var(--foreground) 5%, transparent) 1px,
transparent 1.5px
);
background-size: 24px 24px;
}
.saas-root :global(.frame::before),
.saas-root :global(.frame::after) {
content: '';
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
pointer-events: none;
z-index: 0;
}
.saas-root :global(.frame::before) {
left: 0;
}
.saas-root :global(.frame::after) {
right: 0;
}
.saas-root :global(.frame > *) {
position: relative;
z-index: 1;
}
/* ============ PLUS-ROW (dashed divider + + markers) ============ */
.saas-root :global(.plus-row) {
position: relative;
height: 0;
border-top: 1px dashed var(--border);
margin: 0;
}
.saas-root :global(.plus-row::before),
.saas-root :global(.plus-row::after) {
content: '+';
position: absolute;
top: -8px;
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
font-weight: 400;
color: var(--muted-foreground);
line-height: 1;
background: var(--background);
padding: 1px 3px;
z-index: 2;
}
.saas-root :global(.plus-row::before) {
left: -8px;
}
.saas-root :global(.plus-row::after) {
right: -8px;
}
/* ============ LINE-SEPARATED COURSE GRID ============ */
.saas-root :global(.course-grid) {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
}
.saas-root :global(.course-grid > .course-card-wrap) {
grid-column: span 2;
}
/* 1 course → centered card (drops line frame) */
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child)) {
display: flex;
justify-content: center;
background: transparent;
border: none;
gap: 0;
}
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) > .course-card-wrap) {
width: 100%;
max-width: 520px;
}
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) .course-card) {
border: 1px solid var(--border);
border-radius: 0.5rem;
overflow: hidden;
}
/* 2 courses → halves */
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(2):last-child) > .course-card-wrap) {
grid-column: span 3;
}
/* 4 courses → 2 × 2 halves */
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(4):last-child) > .course-card-wrap) {
grid-column: span 3;
}
/* 6+ courses → all thirds */
.saas-root :global(.course-grid:has(> .course-card-wrap:nth-child(6)) > .course-card-wrap) {
grid-column: span 2;
}
.saas-root :global(.course-card-wrap) {
display: flex;
}
.saas-root :global(.course-card-wrap > a) {
width: 100%;
}
</style>
@@ -0,0 +1,69 @@
<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 EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
}
let { hero, navigation }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
<section class="ui:relative ui:px-6 ui:pt-4 ui:pb-20 ui:md:pb-24">
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:sticky ui:top-4 ui:z-50">
{@render navigation()}
</div>
<div class="ui:relative ui:max-w-[1180px] ui:mx-auto ui:pt-16 ui:md:pt-20 ui:text-center">
<BlurFade delay={0} once={true}>
<h1
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-bold ui:tracking-tight ui:leading-[1.05] ui:max-w-3xl ui:mx-auto ui:mb-5"
>
{hero.heading}
</h1>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<p
class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mx-auto ui:mb-8 ui:leading-relaxed"
>
{hero.subheading}
</p>
</BlurFade>
<BlurFade delay={0.3} once={true}>
<div class="ui:flex ui:items-center ui:justify-center ui:gap-2.5">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-full ui:px-6"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="saas" />
{/if}
</div>
</BlurFade>
{#if hero.image}
<BlurFade delay={0.45} once={true}>
<div class="ui:mt-16 ui:max-w-4xl ui:mx-auto">
<img
src={hero.image}
alt=""
class="ui:w-full ui:h-auto ui:rounded-2xl ui:border ui:border-border ui:shadow-[0_20px_60px_-30px_rgba(14,14,16,0.25)]"
/>
</div>
</BlurFade>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,54 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<EditableLandingSection sectionKey="navigation">
<header
class="ui:flex ui:items-center ui:justify-between ui:gap-4 ui:py-2 ui:pl-5 ui:pr-2 ui:rounded-full ui:border ui:border-border ui:bg-background/85 ui:backdrop-blur-md ui:shadow-[0_4px_24px_-8px_rgba(14,14,16,0.08)]"
>
<a
href="/"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-foreground ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
{/if}
{orgName}
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-1">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:px-3.5 ui:py-1.5 ui:text-sm ui:font-medium ui:text-foreground/80 ui:hover:text-foreground ui:hover:bg-muted/60 ui:rounded-full ui:transition-colors ui:no-underline"
>
{item.label}
</a>
{/each}
</nav>
{/if}
<div class="ui:flex ui:items-center ui:gap-1.5">
{#if authAction}
<Button href={authAction.href} variant="default" size="sm" class="ui:rounded-full ui:px-4">
{authAction.label}
</Button>
{/if}
</div>
</header>
</EditableLandingSection>
@@ -29,6 +29,30 @@
classic: {
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
variant: 'outline'
},
saas: {
className: 'ui:rounded-full ui:px-6 ui:border-border ui:text-foreground ui:hover:bg-accent',
variant: 'outline'
},
tech: {
className:
'ui:rounded-none ui:px-6 ui:font-semibold ui:border-black ui:text-white ui:bg-black ui:hover:bg-black/80 ui:transition-colors',
variant: 'outline'
},
studio: {
className:
'ui:rounded-md ui:px-5 ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted ui:transition-colors',
variant: 'outline'
},
corporate: {
className:
'ui:rounded-none ui:px-6 ui:font-medium ui:border-foreground ui:text-foreground ui:hover:bg-foreground ui:hover:text-background ui:transition-colors',
variant: 'outline'
},
terminal: {
className:
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-white/5 ui:border-[#262a35] ui:text-[#e9eaed] ui:hover:bg-white/10 ui:transition-colors',
variant: 'outline'
}
};
</script>
@@ -0,0 +1,139 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import StudioNav from './studio/nav.svelte';
import StudioHero from './studio/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer
}: 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>
<StudioHero {hero} {courses}>
{#snippet navigation()}
<StudioNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</StudioHero>
{#if courses.length > 0}
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
<div class="ui:max-w-[1080px] ui:mx-auto">
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12 ui:items-end ui:mb-12">
<div>
<p class="ui:text-sm ui:text-muted-foreground ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
<span class="ui:size-1.5 ui:rounded-full ui:bg-primary"></span>
Catalog
</p>
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">
Programs your team can actually finish.
</h2>
</div>
{#if hasMoreCourses}
<div class="ui:flex ui:justify-start ui:md:justify-end">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md"
disabled={disableCourseLinks}
>
Browse all →
</Button>
</div>
{/if}
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-3">
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
<BlurFade delay={0.06 * index} once={true}>
<a
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="ui:group ui:flex ui:flex-col ui:h-full ui:bg-card ui:border ui:border-border ui:rounded-xl ui:overflow-hidden ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:hover:border-foreground/30'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<div
class="ui:relative ui:h-[200px] ui:bg-muted/50 ui:border-b ui:border-border ui:p-5 ui:overflow-hidden"
>
{#if course.logo}
<img
src={course.logo}
alt=""
class="ui:absolute ui:inset-0 ui:w-full ui:h-full ui:object-cover ui:opacity-60"
/>
{/if}
<div
class="ui:relative ui:flex ui:items-start ui:justify-between"
style="background: linear-gradient(180deg, transparent 60%, var(--card) 100%);"
>
{#if courseTypeMeta}
<span
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-2 ui:py-0.5 ui:bg-background/95 ui:border ui:border-border ui:rounded-md ui:text-[11px] ui:font-medium ui:text-foreground"
>
{courseTypeMeta.label}
</span>
{/if}
</div>
</div>
<div class="ui:p-5 ui:flex ui:items-end ui:justify-between ui:gap-3 ui:flex-1">
<div class="ui:flex-1 ui:min-w-0">
<p class="ui:text-[13px] ui:text-muted-foreground ui:mb-1.5 ui:m-0">
{course.price || formatCurrency(course.cost, course.currency)}
</p>
<h3 class="ui:text-base ui:font-medium ui:tracking-tight ui:leading-snug ui:m-0">
{course.title}
</h3>
</div>
<span
class="ui:flex-shrink-0 ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-border ui:text-muted-foreground ui:group-hover:bg-foreground ui:group-hover:text-background ui:group-hover:border-foreground ui:transition-colors"
aria-hidden="true"
>
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} variant="studio" />
<OrgLandingPageEmbed {embed} variant="studio" />
<OrgLandingPageCallout {callout} variant="studio" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
</div>
@@ -0,0 +1,178 @@
<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 EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
navigation: Snippet;
}
let { hero, courses, navigation }: Props = $props();
const programsCount = $derived(courses.length);
const statusLabel = $derived(
programsCount > 0
? `Now in session · ${programsCount} ${programsCount === 1 ? 'program' : 'programs'} running`
: 'Studio opening soon'
);
const marqueeItems = $derived(courses.slice(0, 10));
const showMarquee = $derived(marqueeItems.length > 0);
</script>
{@render navigation()}
<EditableLandingSection sectionKey="hero">
<section class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-20 ui:pb-20 ui:md:pt-24 ui:md:pb-24">
<div
class="ui:pointer-events-none ui:absolute ui:-top-32 ui:-left-24 ui:h-[520px] ui:w-[520px] ui:rounded-full ui:opacity-60 ui:blur-3xl"
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 35%, transparent) 0%, transparent 70%);"
aria-hidden="true"
></div>
<div
class="ui:pointer-events-none ui:absolute ui:-top-20 ui:right-[-10%] ui:h-[420px] ui:w-[420px] ui:rounded-full ui:opacity-40 ui:blur-3xl"
style="background: radial-gradient(circle, color-mix(in oklab, var(--primary) 18%, transparent) 0%, transparent 70%);"
aria-hidden="true"
></div>
<div class="ui:relative ui:max-w-[1080px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<span
class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-7 ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-border ui:bg-card/70 ui:backdrop-blur-sm ui:text-[12px] ui:font-medium ui:text-foreground"
>
<span class="studio-status-dot ui:size-1.5 ui:rounded-full" aria-hidden="true"></span>
{statusLabel}
</span>
</BlurFade>
<BlurFade delay={0.08} once={true}>
<h1
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[64px] ui:font-semibold ui:tracking-tight ui:leading-[1.02] ui:max-w-[18ch] ui:mb-5 ui:text-foreground ui:text-balance"
>
{hero.heading}
</h1>
</BlurFade>
<BlurFade delay={0.18} once={true}>
<p class="ui:text-base ui:md:text-lg ui:text-muted-foreground ui:max-w-xl ui:mb-8 ui:leading-relaxed">
{hero.subheading}
</p>
</BlurFade>
<BlurFade delay={0.28} once={true}>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
class="ui:rounded-md ui:px-5"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="studio"
/>
{/if}
</div>
</BlurFade>
{#if hero.image}
<BlurFade delay={0.4} once={true}>
<div
class="studio-polaroid ui:mt-16 ui:relative ui:rounded-xl ui:border ui:border-border ui:bg-card ui:p-2 ui:shadow-[0_40px_90px_-30px_rgba(0,0,0,0.35)]"
>
<img src={hero.image} alt="" class="ui:w-full ui:h-auto ui:rounded-lg ui:border ui:border-border" />
</div>
</BlurFade>
{/if}
{#if showMarquee}
<BlurFade delay={hero.image ? 0.5 : 0.4} once={true}>
<div class="studio-marquee-mask ui:mt-16 ui:py-4 ui:border-y ui:border-border" aria-hidden="true">
<div class="studio-marquee-track ui:flex ui:gap-12 ui:whitespace-nowrap ui:w-max">
{#each [...marqueeItems, ...marqueeItems] as course, index (`${course.id}-${index}`)}
<span class="ui:inline-flex ui:items-center ui:gap-3">
<span class="ui:size-1 ui:rounded-full ui:bg-primary/70"></span>
<span class="ui:text-sm ui:font-medium ui:text-muted-foreground">
{course.title}
</span>
</span>
{/each}
</div>
</div>
</BlurFade>
{/if}
</div>
</section>
</EditableLandingSection>
<style>
.studio-status-dot {
background-color: var(--primary);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 60%, transparent);
animation: studio-pulse 2s ease-out infinite;
}
@keyframes studio-pulse {
0% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 50%, transparent);
}
70% {
box-shadow: 0 0 0 8px color-mix(in oklab, var(--primary) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
}
}
.studio-polaroid {
transform: rotate(-0.8deg);
transition: transform 400ms ease;
}
.studio-polaroid:hover {
transform: rotate(0deg);
}
.studio-marquee-mask {
overflow: hidden;
mask-image: linear-gradient(to right, transparent 0, black 8%, black 92%, transparent 100%);
-webkit-mask-image: linear-gradient(to right, transparent 0, black 8%, black 92%, transparent 100%);
}
.studio-marquee-track {
animation: studio-marquee 40s linear infinite;
}
.studio-marquee-mask:hover .studio-marquee-track {
animation-play-state: paused;
}
@keyframes studio-marquee {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@media (prefers-reduced-motion: reduce) {
.studio-status-dot,
.studio-marquee-track {
animation: none;
}
.studio-polaroid {
transform: none;
transition: none;
}
}
</style>
@@ -0,0 +1,58 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:sticky ui:top-0 ui:z-50 ui:bg-background/75 ui:backdrop-blur-md ui:border-b ui:border-border">
<div class="ui:max-w-[1080px] ui:mx-auto ui:px-6">
<div class="ui:grid ui:grid-cols-[1fr_auto_1fr] ui:items-center ui:h-14">
<a
href="/"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[14px] ui:tracking-tight ui:text-foreground ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-5 ui:w-auto" />
{/if}
{orgName}
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-0.5 ui:justify-self-center">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:px-3 ui:py-1.5 ui:text-[13px] ui:text-muted-foreground ui:hover:text-foreground ui:hover:bg-muted ui:rounded-md ui:transition-colors ui:no-underline"
>
{item.label}
</a>
{/each}
</nav>
{:else}
<span></span>
{/if}
<div class="ui:justify-self-end ui:flex ui:items-center ui:gap-1">
{#if authAction}
<Button href={authAction.href} size="sm" class="ui:rounded-md ui:px-3.5">
{authAction.label}
</Button>
{/if}
</div>
</div>
</div>
</header>
</EditableLandingSection>
@@ -0,0 +1,133 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TechNav from './tech/nav.svelte';
import TechHero from './tech/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import ClockIcon from '@lucide/svelte/icons/clock';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer
}: 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>
<TechHero {hero}>
{#snippet navigation()}
<TechNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</TechHero>
{#if courses.length > 0}
<section class="ui:py-24 ui:px-6">
<div class="ui:max-w-[1280px] ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:flex-wrap ui:gap-4 ui:mb-14">
<div>
<p class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary ui:mb-3">// catalog</p>
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">Latest courses</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-none ui:font-mono ui:text-sm"
disabled={disableCourseLinks}
>
view all →
</Button>
{/if}
</div>
<div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border-t ui:border-l ui:border-border"
>
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
<BlurFade delay={0.06 * index} once={true}>
<a
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="ui:flex ui:flex-col ui:h-full ui:p-7 ui:border-r ui:border-b ui:border-border ui:bg-background ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:hover:bg-muted/40'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{#if courseTypeMeta}
<span
class="ui:inline-block ui:self-start ui:px-2.5 ui:py-1 ui:bg-primary ui:text-primary-foreground ui:font-mono ui:text-[10px] ui:font-semibold ui:tracking-wider ui:uppercase ui:mb-4"
>
{courseTypeMeta.label}
</span>
{/if}
<h3 class="ui:text-xl ui:font-bold ui:tracking-tight ui:leading-tight ui:mb-3 ui:m-0">
{course.title}
</h3>
<p
class="ui:text-sm ui:text-muted-foreground ui:leading-relaxed ui:line-clamp-3 ui:flex-1 ui:m-0 ui:mb-5"
>
{course.description}
</p>
<div
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:border-t ui:border-dashed ui:border-border"
>
<span class="ui:font-mono ui:text-sm ui:font-bold">
{course.price || formatCurrency(course.cost, course.currency)}
</span>
<span
class="ui:inline-flex ui:items-center ui:gap-1 ui:font-mono ui:text-xs ui:text-primary ui:font-bold"
aria-hidden="true"
>
enroll
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
{#if course.duration}
<p
class="ui:font-mono ui:text-[11px] ui:text-muted-foreground ui:mt-3 ui:inline-flex ui:items-center ui:gap-1.5"
>
<ClockIcon class="ui:size-3" />
{course.duration}
</p>
{/if}
</a>
</BlurFade>
{/each}
</div>
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} variant="tech" />
<OrgLandingPageEmbed {embed} variant="tech" />
<OrgLandingPageCallout {callout} variant="tech" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
</div>
@@ -0,0 +1,87 @@
<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 EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
}
let { hero, navigation }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
<section class="ui:relative ui:bg-primary ui:text-primary-foreground ui:overflow-hidden ui:px-6 ui:pt-2 ui:pb-20">
<!-- subtle grid overlay -->
<div
class="ui:pointer-events-none ui:absolute ui:inset-0 ui:opacity-[0.08]"
style="background-image: linear-gradient(currentColor 1px, transparent 1px), linear-gradient(90deg, currentColor 1px, transparent 1px); background-size: 56px 56px;"
aria-hidden="true"
></div>
<div class="ui:relative ui:max-w-[1280px] ui:mx-auto">
{@render navigation()}
</div>
<div
class="ui:relative ui:max-w-[1280px] ui:mx-auto ui:py-16 ui:lg:py-24 ui:grid ui:lg:grid-cols-2 ui:gap-12 ui:items-center"
>
<div>
<BlurFade delay={0} once={true}>
<p
class="ui:inline-block ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-primary-foreground/80 ui:bg-primary-foreground/10 ui:px-3 ui:py-1.5 ui:mb-6"
>
// engineering academy
</p>
</BlurFade>
<BlurFade delay={0.12} once={true}>
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-extrabold ui:tracking-tight ui:leading-[0.95] ui:mb-6">
{hero.heading}
</h1>
</BlurFade>
<BlurFade delay={0.24} once={true}>
<p class="ui:text-lg ui:lg:text-xl ui:text-primary-foreground/80 ui:max-w-xl ui:mb-8 ui:leading-relaxed">
{hero.subheading}
</p>
</BlurFade>
<BlurFade delay={0.36} once={true}>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
variant="secondary"
class="ui:rounded-none ui:px-7 ui:font-semibold ui:bg-primary-foreground ui:text-primary ui:hover:bg-primary-foreground/90"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="tech"
/>
{/if}
</div>
</BlurFade>
</div>
{#if hero.image}
<BlurFade delay={0.3} once={true} class="ui:hidden ui:lg:block">
<div class="ui:relative">
<div class="ui:absolute ui:inset-0 ui:bg-primary-foreground/10 ui:translate-x-3 ui:translate-y-3"></div>
<img
src={hero.image}
alt=""
class="ui:relative ui:w-full ui:h-[460px] ui:object-cover ui:border ui:border-primary-foreground/20"
/>
</div>
</BlurFade>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-primary-foreground">
<a
href="/"
class="ui:flex ui:items-center ui:gap-2 ui:font-bold ui:text-base ui:tracking-tight ui:text-primary-foreground ui:no-underline"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
{/if}
{orgName}
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-1.5 ui:font-mono ui:text-[13px]">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-transparent ui:text-primary-foreground/85 ui:hover:text-primary-foreground ui:hover:border-primary-foreground/30 ui:hover:bg-primary-foreground/10 ui:transition-colors ui:no-underline"
>
( {item.label.toLowerCase()} )
</a>
{/each}
</nav>
{/if}
{#if authAction}
<Button
href={authAction.href}
size="sm"
variant="outline"
class="ui:rounded-none ui:font-semibold ui:border-primary-foreground ui:text-primary-foreground ui:bg-transparent ui:hover:bg-primary-foreground/10 ui:hover:text-primary-foreground"
>
{authAction.label} →
</Button>
{/if}
</header>
</EditableLandingSection>
@@ -0,0 +1,228 @@
<script lang="ts">
import type { OrgLandingPageProps, CourseItem } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TerminalNav from './terminal/nav.svelte';
import TerminalHero from './terminal/hero.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer
}: OrgLandingPageProps = $props();
function priceLabel(course: CourseItem): string {
if (course.price) return course.price;
if (!course.cost) return 'Free';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD',
maximumFractionDigits: 0
}).format(course.cost);
}
type TabKey = 'all' | string;
const courseTypeKeys = $derived(
Array.from(new Set(courses.map((course) => course.type).filter((t): t is string => Boolean(t))))
);
const tabs = $derived<{ key: TabKey; label: string }[]>([
{ key: 'all', label: 'All' },
...courseTypeKeys.map((type) => {
const meta = getCourseTypeLandingMeta({ id: '', title: '', description: '', type } as CourseItem);
return { key: type, label: meta?.label ?? type };
})
]);
let activeTab = $state<TabKey>('all');
const visibleCourses = $derived(
activeTab === 'all' ? courses : courses.filter((course) => course.type === activeTab)
);
function slugify(course: CourseItem): string {
if (course.slug) return course.slug;
return course.title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
</script>
<div
class="ui:min-h-screen ui:font-sans ui:w-full"
style="
background: #06070a;
color: #e9eaed;
--terminal-bg: #06070a;
--terminal-bg-soft: #0c0e13;
--terminal-bg-card: #0f1218;
--terminal-bg-elevated: #14171f;
--terminal-line: #1c1f28;
--terminal-line-soft: #161922;
--terminal-line-strong: #262a35;
--terminal-ink: #e9eaed;
--terminal-ink-dim: #9da1ab;
--terminal-ink-muted: #61656f;
--terminal-ink-faint: #3d4049;
"
>
<main>
<TerminalHero {orgName} {hero} {courses}>
{#snippet navigation()}
<TerminalNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</TerminalHero>
{#if courses.length > 0}
<section
class="ui:py-24 ui:px-6"
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg);"
>
<div class="ui:max-w-[1120px] ui:mx-auto">
<BlurFade delay={0} once={true}>
<p
class="ui:font-mono ui:text-[11px] ui:tracking-[0.12em] ui:uppercase ui:mb-3 ui:inline-flex ui:items-center ui:gap-2"
style="color: var(--primary);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--primary); box-shadow: 0 0 12px var(--primary);"
></span>
Catalog
</p>
</BlurFade>
<BlurFade delay={0.08} once={true}>
<h2
class="ui:text-4xl ui:lg:text-[40px] ui:font-semibold ui:tracking-tight ui:leading-[1.08] ui:m-0 ui:mb-3 ui:max-w-[700px]"
style="color: var(--terminal-ink);"
>
Programs your team can actually finish.
</h2>
</BlurFade>
<BlurFade delay={0.14} once={true}>
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8" style="color: var(--terminal-ink-dim);">
Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.
</p>
</BlurFade>
{#if tabs.length > 1}
<BlurFade delay={0.2} once={true}>
<div
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8"
style="border: 1px solid var(--terminal-line-strong); background: var(--terminal-bg-card);"
role="tablist"
>
{#each tabs as tab (tab.key)}
{@const isActive = activeTab === tab.key}
<button
type="button"
role="tab"
aria-selected={isActive}
onclick={() => (activeTab = tab.key)}
class="ui:px-3.5 ui:py-1.5 ui:text-[13px] ui:font-medium ui:rounded-full ui:transition-colors"
style={isActive
? 'background: var(--terminal-ink); color: var(--terminal-bg);'
: 'background: transparent; color: var(--terminal-ink-dim);'}
>
{tab.label}
</button>
{/each}
</div>
</BlurFade>
{/if}
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[14px]">
{#each visibleCourses as course, index (course.id)}
<BlurFade delay={0.04 * index} once={true}>
<a
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
class="ui:relative ui:flex ui:flex-col ui:gap-2.5 ui:p-6 ui:rounded-xl ui:no-underline ui:transition-colors ui:h-full"
style="
background: linear-gradient(180deg, var(--terminal-bg-card) 0%, var(--terminal-bg-soft) 100%);
border: 1px solid var(--terminal-line);
color: var(--terminal-ink);
min-height: 180px;
cursor: {disableCourseLinks ? 'default' : 'pointer'};
"
onmouseenter={(e) =>
!disableCourseLinks && (e.currentTarget.style.borderColor = 'var(--terminal-line-strong)')}
onmouseleave={(e) => (e.currentTarget.style.borderColor = 'var(--terminal-line)')}
>
<span class="ui:font-mono ui:text-[11px] ui:tracking-[0.04em]" style="color: var(--primary);">
$ {slugify(course)}
</span>
<h3
class="ui:text-[17px] ui:font-semibold ui:leading-snug ui:m-0"
style="color: var(--terminal-ink); letter-spacing: -0.01em;"
>
{course.title}
</h3>
<p
class="ui:text-[13.5px] ui:leading-[1.5] ui:m-0 ui:flex-1 ui:line-clamp-3"
style="color: var(--terminal-ink-dim);"
>
{course.description}
</p>
<div
class="ui:flex ui:items-center ui:justify-between ui:pt-3.5 ui:font-mono ui:text-[12px]"
style="border-top: 1px dashed var(--terminal-line);"
>
<span style="color: var(--terminal-ink);">{priceLabel(course)}</span>
<span aria-hidden="true" style="color: var(--terminal-ink-dim);">→</span>
</div>
</a>
</BlurFade>
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:mt-7 ui:flex ui:justify-center">
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
disabled={disableCourseLinks}
class="ui:rounded-full ui:px-5 ui:bg-transparent ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/5"
>
View all programs →
</Button>
</div>
{/if}
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} variant="terminal" />
<OrgLandingPageEmbed {embed} variant="terminal" />
<OrgLandingPageCallout {callout} variant="terminal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
</div>
@@ -0,0 +1,297 @@
<script lang="ts">
import type { OrgLandingPageProps, CourseItem } from '../types';
import type { Snippet } from 'svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: OrgLandingPageProps['orgName'];
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
navigation: Snippet;
}
let { orgName, hero, courses, navigation }: Props = $props();
const programsCount = $derived(courses.length);
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
const tracksCount = $derived(new Set(courses.map((course) => course.type).filter(Boolean)).size);
const numberFormatter = new Intl.NumberFormat('en-US');
const learnersFormatted = $derived(numberFormatter.format(learnersCount));
function priceLabel(course: CourseItem): string {
if (course.price) return course.price;
if (!course.cost) return 'Free';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD',
maximumFractionDigits: 0
}).format(course.cost);
}
function durationLabel(course: CourseItem): string {
if (course.duration) return course.duration;
if (course.lessonCount && course.lessonCount > 0) {
return `${course.lessonCount} ${course.lessonCount === 1 ? 'lesson' : 'lessons'}`;
}
return '—';
}
const terminalRows = $derived(
courses.slice(0, 5).map((course, index) => ({
n: String(index + 1).padStart(2, '0'),
slug: course.slug ?? course.id,
price: priceLabel(course),
duration: durationLabel(course)
}))
);
const orgSlug = $derived(
orgName
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'org'
);
</script>
{@render navigation()}
<EditableLandingSection sectionKey="hero">
<section
class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-16 ui:pb-16 ui:md:pt-20 ui:md:pb-20"
style="
background: #06070a;
--terminal-bg: #06070a;
--terminal-bg-soft: #0c0e13;
--terminal-bg-card: #0f1218;
--terminal-bg-elevated: #14171f;
--terminal-line: #1c1f28;
--terminal-line-soft: #161922;
--terminal-line-strong: #262a35;
--terminal-ink: #e9eaed;
--terminal-ink-dim: #9da1ab;
--terminal-ink-muted: #61656f;
--terminal-ink-faint: #3d4049;
"
>
<div
class="ui:pointer-events-none ui:absolute ui:inset-x-0 ui:-top-32 ui:h-[680px] ui:opacity-90"
style="background: radial-gradient(ellipse 60% 70% at 50% 30%, color-mix(in oklab, var(--primary) 22%, transparent) 0%, transparent 65%);"
aria-hidden="true"
></div>
<div class="ui:relative ui:max-w-[1120px] ui:mx-auto ui:text-center">
<BlurFade delay={0} once={true}>
<h1
class="ui:font-semibold ui:leading-[0.95] ui:mb-10 ui:max-w-[20ch] ui:mx-auto ui:text-balance"
style="
font-size: clamp(48px, 9vw, 128px);
letter-spacing: -0.04em;
background: linear-gradient(180deg,
color-mix(in oklab, var(--primary) 60%, #ffffff) 0%,
color-mix(in oklab, var(--primary) 35%, transparent) 45%,
rgba(8, 10, 14, 0.3) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 8px 60px color-mix(in oklab, var(--primary) 18%, transparent);
user-select: none;
"
>
{hero.heading}
</h1>
</BlurFade>
<BlurFade delay={0.12} once={true}>
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
{#if hero.secondaryAction}
<Button
href={hero.secondaryAction.href}
size="sm"
variant="outline"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-white/5 ui:border-[var(--terminal-line-strong)] ui:text-[var(--terminal-ink)] ui:hover:bg-white/10"
>
{hero.secondaryAction.label}
</Button>
{/if}
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="sm"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
>
{hero.primaryAction.label}
</Button>
</div>
</BlurFade>
<BlurFade delay={0.22} once={true}>
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto" style="color: var(--terminal-ink-dim);">
{hero.subheading}
</p>
</BlurFade>
{#if programsCount > 0}
<BlurFade delay={0.3} once={true}>
<span
class="ui:inline-flex ui:items-center ui:gap-3 ui:px-4 ui:py-2 ui:rounded-full ui:font-mono ui:text-[13px]"
style="
border: 1px solid var(--terminal-line-strong);
background: rgba(255,255,255,0.02);
backdrop-filter: blur(8px);
color: var(--terminal-ink-dim);
"
>
<span style="color: var(--primary);">&gt;</span>
<span
><span style="color: var(--terminal-ink); font-weight: 500;">{programsCount}</span>
{programsCount === 1 ? 'program' : 'programs'} running</span
>
{#if learnersCount > 0}
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
<span
><span style="color: var(--terminal-ink); font-weight: 500;">{learnersFormatted}</span> learners</span
>
{/if}
{#if tracksCount > 1}
<span class="ui:inline-block ui:w-px ui:h-3" style="background: var(--terminal-line-strong);"></span>
<span><span style="color: var(--terminal-ink); font-weight: 500;">{tracksCount}</span> tracks</span>
{/if}
</span>
</BlurFade>
{/if}
{#if hero.image}
<BlurFade delay={0.4} once={true}>
<div
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
style="
border: 1px solid var(--terminal-line-strong);
background: var(--terminal-bg-card);
box-shadow:
0 30px 90px -30px rgba(0,0,0,0.7),
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
"
>
<div
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
>
<span class="ui:flex ui:gap-1.5 ui:mr-2">
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
<span class="ui:size-[11px] ui:rounded-full" style="background: #febc2e;"></span>
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
</span>
<span
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
style="color: var(--terminal-ink-dim);"
>
{orgSlug}/preview
</span>
</div>
<img src={hero.image} alt={orgName} class="ui:block ui:w-full ui:h-auto" />
</div>
</BlurFade>
{:else if terminalRows.length > 0}
<BlurFade delay={0.4} once={true}>
<div
class="ui:mt-14 ui:max-w-[880px] ui:mx-auto ui:rounded-xl ui:overflow-hidden ui:text-left"
style="
border: 1px solid var(--terminal-line-strong);
background: var(--terminal-bg-card);
box-shadow:
0 30px 90px -30px rgba(0,0,0,0.7),
0 0 0 1px color-mix(in oklab, var(--primary) 6%, transparent);
"
>
<div
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3"
style="border-bottom: 1px solid var(--terminal-line); background: var(--terminal-bg-elevated);"
>
<span class="ui:flex ui:gap-1.5 ui:mr-2">
<span class="ui:size-[11px] ui:rounded-full" style="background: #ff5f57;"></span>
<span class="ui:size-[11px] ui:rounded-full" style="background: #febc2e;"></span>
<span class="ui:size-[11px] ui:rounded-full" style="background: #28c840;"></span>
</span>
<span
class="ui:flex-1 ui:text-center ui:font-mono ui:text-xs ui:truncate"
style="color: var(--terminal-ink-dim);"
>
{orgSlug}/main · catalog
</span>
<span class="ui:font-mono ui:text-[11px]" style="color: var(--terminal-ink-muted);">
{programsCount}
{programsCount === 1 ? 'program' : 'programs'}
</span>
</div>
<div class="ui:p-5 ui:font-mono ui:text-[13px]" style="color: var(--terminal-ink); line-height: 1.7;">
<div
class="ui:flex ui:items-baseline ui:gap-2 ui:px-3 ui:py-2 ui:rounded-md ui:mb-3"
style="background: rgba(255,255,255,0.02); border: 1px solid var(--terminal-line);"
>
<span style="color: var(--primary);">&gt;</span>
<span>list programs --org {orgSlug}</span>
</div>
<div class="ui:py-1 ui:text-[12px]" style="color: var(--terminal-ink-muted);">
● Loaded {programsCount}
{programsCount === 1 ? 'program' : 'programs'}
</div>
<div class="ui:mt-2 ui:flex ui:flex-col">
{#each terminalRows as row (row.slug)}
<div class="ui:flex ui:items-baseline ui:gap-4 ui:py-1.5" style="color: var(--terminal-ink);">
<span style="color: var(--terminal-ink-faint);">{row.n}</span>
<span class="ui:truncate ui:flex-1" style="color: var(--primary);">{row.slug}</span>
<span class="ui:text-[12px]" style="color: var(--terminal-ink-dim);">{row.duration}</span>
<span class="ui:text-[12px] ui:w-20 ui:text-right">{row.price}</span>
</div>
{/each}
</div>
<div class="ui:flex ui:items-baseline ui:gap-2 ui:mt-4">
<span style="color: var(--primary);">&gt;</span>
<span
class="ui:inline-block ui:w-2 ui:h-[14px] ui:align-middle"
style="background: var(--primary); animation: terminal-caret 1s steps(1) infinite;"
aria-hidden="true"
></span>
</div>
</div>
<div
class="ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2.5 ui:font-mono ui:text-[11px]"
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg-soft); color: var(--terminal-ink-muted);"
>
<div class="ui:flex ui:gap-4">
<span>Enter enroll</span>
<span>↑/↓ navigate</span>
<span>^q quit</span>
</div>
<div style="color: var(--terminal-ink-dim);">{orgSlug}-cli</div>
</div>
</div>
</BlurFade>
{/if}
</div>
</section>
</EditableLandingSection>
<style>
@keyframes terminal-caret {
50% {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
[style*='terminal-caret'] {
animation: none !important;
}
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import type { NavItem } from '../types';
import { Button } from '../../../base/button';
import EditableLandingSection from '../editable-section.svelte';
interface Props {
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
}
let { orgName, logoUrl, navItems, authAction }: Props = $props();
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:relative ui:z-50 ui:px-6" style="background: transparent;">
<div class="ui:max-w-[1120px] ui:mx-auto">
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
<div class="ui:flex ui:items-center ui:gap-8">
<a
href="/"
aria-label={orgName}
class="ui:flex ui:items-center ui:gap-2 ui:no-underline ui:text-foreground"
style="color: var(--terminal-ink, #e9eaed);"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
{:else}
<svg viewBox="0 0 16 16" fill="none" class="ui:size-5">
<path
d="M2 2 L8 8 L2 14 M14 2 L8 8 L14 14"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
{/if}
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:items-center ui:gap-6">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:no-underline ui:transition-colors"
style="color: var(--terminal-ink-dim, #9da1ab);"
onmouseenter={(e) => (e.currentTarget.style.color = 'var(--terminal-ink, #e9eaed)')}
onmouseleave={(e) => (e.currentTarget.style.color = 'var(--terminal-ink-dim, #9da1ab)')}
>
{item.label}
</a>
{/each}
<span class="ui:select-none" style="color: var(--terminal-ink-faint, #3d4049);" aria-hidden="true">/</span
>
</nav>
{/if}
</div>
<div class="ui:flex ui:items-center ui:gap-3">
{#if authAction}
<Button
href={authAction.href}
size="sm"
variant="outline"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-transparent ui:border-[var(--terminal-line-strong,#262a35)] ui:text-[var(--terminal-ink,#e9eaed)] ui:hover:bg-white/5"
>
{authAction.label}
</Button>
{/if}
</div>
</div>
</div>
</header>
</EditableLandingSection>
@@ -1,6 +1,14 @@
export type NavItem = { label: string; href: string };
export type OrgLandingPageTheme = 'minimal' | 'bold' | 'classic';
export type OrgLandingPageTheme =
| 'minimal'
| 'bold'
| 'classic'
| 'saas'
| 'tech'
| 'studio'
| 'corporate'
| 'terminal';
export type CourseItem = {
id: string;
@@ -33,7 +41,39 @@ export type CourseItem = {
level?: string;
};
export type FooterLink = { label: string; href: string };
export type FooterSocialPlatform =
| 'instagram'
| 'x'
| 'linkedin'
| 'facebook'
| 'youtube'
| 'github'
| 'tiktok'
| 'website';
export type FooterSocial = { platform: FooterSocialPlatform; href: string };
export type FooterColumnLink = { id: string; label: string; href: string };
export type FooterColumn = {
id: string;
heading: string;
links: FooterColumnLink[];
cta?: { label: string; href: string };
};
export type OrgLandingPageFooterConfig = {
brand: {
tagline?: string;
copyright?: string;
socials: FooterSocial[];
};
columns: FooterColumn[];
bottom?: {
text?: string;
links: FooterColumnLink[];
};
};
export type LandingPageEmbed = {
title: string;
description?: string;
@@ -53,6 +93,35 @@ export type LandingPageCallout = {
};
};
export type LandingPageLinkIcon =
| 'help-circle'
| 'life-buoy'
| 'book-open'
| 'video'
| 'users'
| 'message-circle'
| 'newspaper'
| 'rocket'
| 'calendar'
| 'mail';
export type LandingPageLinkCard = {
icon: LandingPageLinkIcon;
title: string;
description: string;
href: string;
};
export type LandingPageLinks = {
heading: string;
description?: string;
/** Bold template card footer chip (e.g. Visit). */
boldVisitLabel?: string;
/** Classic template card footer (e.g. Learn more). */
classicLearnMoreLabel?: string;
cards: LandingPageLinkCard[];
};
export interface OrgLandingPageProps {
orgName: string;
logoUrl?: string;
@@ -67,12 +136,13 @@ export interface OrgLandingPageProps {
primaryAction: { label: string; href: string; disabled?: boolean };
secondaryAction?: { label: string; href: string };
image?: string;
stats?: Array<{ label: string; value: string }>;
};
courses: CourseItem[];
hasMoreCourses?: boolean;
disableCourseLinks?: boolean;
embed?: LandingPageEmbed;
callout?: LandingPageCallout;
footerLinks: FooterLink[];
footerText: string;
links?: LandingPageLinks;
footer: OrgLandingPageFooterConfig;
}