feat: preview templates, thumbnail features, 2 new org templates

This commit is contained in:
Rotimi Best
2026-05-23 12:24:57 +01:00
parent 4fdd4115b9
commit 78ab13f54b
106 changed files with 8320 additions and 2040 deletions
+34
View File
@@ -118,6 +118,40 @@ export async function enqueueLessonVideoPipeline(
};
}
/**
* Enqueue only generate-thumbnail (new `media_job` row required). Used by the
* "regenerate thumbnails" action from the media manager. Skips probe — the
* asset already has a duration from the original pipeline.
*/
export async function enqueueGenerateThumbnailOnly(input: {
mediaJobId: string;
assetId: string;
storageKey: string;
actorContext: TActorContext;
}): Promise<EnqueueLessonVideoPipelineResult> {
const { mediaJobId, assetId, storageKey, actorContext } = input;
const sharedData = {
mediaJobId,
assetId,
storageKey,
actorContext,
markJobCompleteOnSuccess: true
};
const job = await getQueue(QUEUE_NAMES.media).add(
JOB_NAMES.media.generateThumbnail,
sharedData,
QUEUE_DEFAULTS[QUEUE_NAMES.media]
);
return {
rootJobId: job.id ?? '',
jobIds: {
[JOB_NAMES.media.generateThumbnail]: job.id ?? ''
}
};
}
/**
* Enqueue only extract-audio → transcribe-audio (new `media_job` row required).
* Used for manual “generate transcript” when the initial pipeline had no OpenAI key.
+7 -1
View File
@@ -29,7 +29,13 @@ export const ZGenerateThumbnailPayload = z.object({
storageKey: z.string().min(1),
actorContext: ZActorContext,
/** Seconds into the video to grab. Defaults to 1.0 in the worker. */
atSeconds: z.number().nonnegative().optional()
atSeconds: z.number().nonnegative().optional(),
/**
* When true, the processor marks the parent `media_job` as `completed`
* after a successful run. Set by `enqueueGenerateThumbnailOnly` because
* thumbnail is the only step and nothing else will flip status.
*/
markJobCompleteOnSuccess: z.boolean().optional()
});
export type TGenerateThumbnailPayload = z.infer<typeof ZGenerateThumbnailPayload>;
@@ -8,9 +8,11 @@
TechLandingPage,
StudioLandingPage,
CorporateLandingPage,
TerminalLandingPage
TerminalLandingPage,
EditorialLandingPage,
VibrantLandingPage,
mockOrgLandingPageProps as mockProps
} from '@cio/ui/custom/org-landing-page';
import { mockProps } from './fixtures';
const { Story } = defineMeta({
title: 'Templates/Org Landing Page',
@@ -19,6 +21,27 @@
},
tags: ['autodocs']
});
/** Example of overriding UI chrome labels (e.g. via the dashboard's translations). */
const localizedLabels = {
catalogEyebrow: 'Catálogo',
catalogHeading: 'Programas que empiezan esta temporada.',
catalogDescription:
'Cohortes y a tu propio ritmo — cada programa termina con un proyecto revisado y un certificado.',
browseCoursesLabel: 'Ver todos →',
enrollLabel: 'Inscribirme',
freeLabel: 'Gratis',
featuredLabel: 'Destacado',
filterAllLabel: 'Todos',
startCourseLabel: 'Empezar curso',
resourcesEyebrow: 'Recursos',
embedEyebrow: 'Integración',
calloutEyebrow: 'Cuando quieras',
learnMoreLabel: 'Visitar',
lessonsLabel: (n) => (n === 1 ? '1 lección' : `${n} lecciones`),
exercisesLabel: (n) => (n === 1 ? '1 ejercicio' : `${n} ejercicios`),
enrolledLabel: (n) => `${n.toLocaleString()} inscritos`
};
</script>
<Story name="Minimal">
@@ -52,3 +75,19 @@
<Story name="Terminal">
<TerminalLandingPage {...mockProps} />
</Story>
<Story name="Editorial">
<EditorialLandingPage {...mockProps} />
</Story>
<Story name="Vibrant">
<VibrantLandingPage {...mockProps} />
</Story>
<!--
Demonstrates the OrgLandingPageLabels override surface. Every UI-chrome
string is replaced via the `labels` prop; per-template defaults disappear.
-->
<Story name="Editorial (localized labels)">
<EditorialLandingPage {...mockProps} labels={localizedLabels} />
</Story>
@@ -160,7 +160,7 @@
{#if isYouTube && youtubeVideoId}
<div
bind:this={containerElement}
class="plyr__video-embed ui:absolute ui:inset-0 ui:h-full ui:w-full"
class="plyr__video-embed ui:h-full ui:w-full"
data-plyr-provider="youtube"
data-plyr-embed-id={youtubeVideoId}
></div>
@@ -171,7 +171,7 @@
{src}
{poster}
{playsinline}
class="plyr-player ui:absolute ui:inset-0 ui:h-full ui:w-full ui:object-contain"
class="plyr-player ui:h-full ui:w-full ui:object-contain"
>
{#each tracks as track (track.src + track.srclang)}
<track
@@ -6,14 +6,10 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
import BoldHero from './bold/hero.svelte';
import BoldCourseCard from './bold/course-card.svelte';
import { Button } from '../../base/button';
import * as Card from '../../base/card';
import { BlurFade } from '../animation/blurfade';
import { BorderBeam } from '../animation/border-beam';
import { DotPattern } from '../animation/dot-pattern';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -27,108 +23,29 @@
embed,
callout,
links,
footer
footer,
labels
}: 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">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('bold')}
>
<BoldNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
<BoldHero {hero} />
<section class="ui:relative ui:bg-muted/30 ui:px-6 ui:overflow-hidden ui:mb-12">
<section class="ui:relative ui:bg-[var(--landing-card-soft)]/30 ui:px-6 ui:overflow-hidden ui:mb-12">
<DotPattern class="ui:opacity-[0.15]" />
<div class="ui:relative ui:max-w-7xl ui:mx-auto">
<div class="ui:flex ui:items-end ui:justify-between ui:mb-12">
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">Latest Courses</h2>
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight">{labels?.catalogHeading ?? 'Latest Courses'}</h2>
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-8">
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const primaryTag = getPrimaryCourseTag(course)}
<BlurFade delay={0.1 * index} once={true}>
<Card.Root
class="ui:group ui:relative ui:overflow-hidden ui:bg-background ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-border/50 {disableCourseLinks
? ''
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
>
<BorderBeam
size={120}
duration={8}
colorFrom="var(--primary)"
colorTo="color-mix(in oklab, var(--primary) 65%, white)"
/>
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground ui:bg-muted ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<span>{course.duration}</span>
</div>
{/if}
{#if 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"
>
<TagIcon class="ui:size-3.5 ui:text-primary" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg"
>{course.price || formatCurrency(course.cost, course.currency)}</span
>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:hover:bg-primary ui:hover:text-primary-foreground ui:group-hover:bg-primary ui:group-hover:text-primary-foreground ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
Details
</Button>
</div>
</Card.Content>
</Card.Root>
</BlurFade>
<BoldCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
@@ -140,7 +57,7 @@
class="ui:rounded-xl ui:px-8"
disabled={disableCourseLinks}
>
View more courses
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
@@ -148,11 +65,11 @@
</section>
</main>
<OrgLandingPageLinks {links} variant="bold" />
<OrgLandingPageLinks {links} {labels} variant="bold" />
<OrgLandingPageEmbed {embed} variant="bold" />
<OrgLandingPageEmbed {embed} {labels} variant="bold" />
<OrgLandingPageCallout {callout} variant="bold" />
<OrgLandingPageCallout {callout} {labels} variant="bold" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import * as Card from '../../../base/card';
import { Button } from '../../../base/button';
import { BorderBeam } from '../../animation/border-beam';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const primaryTag = $derived(getPrimaryCourseTag(course));
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<Card.Root
class="ui:group ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:rounded-3xl ui:p-4 ui:flex-col ui:sm:flex-row ui:gap-6 ui:duration-300 ui:h-full ui:border-[var(--landing-border)]/50 {disableCourseLinks
? ''
: 'ui:hover:shadow-xl ui:hover:shadow-primary/5 ui:transition-all'}"
>
<BorderBeam
size={120}
duration={8}
colorFrom="var(--landing-accent)"
colorTo="color-mix(in oklab, var(--landing-accent) 65%, white)"
/>
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:sm:w-48 ui:h-48 ui:object-cover ui:rounded-2xl ui:group-hover:scale-[1.02] ui:transition-transform ui:duration-500"
/>
{/if}
<Card.Content class="ui:p-2 ui:flex ui:flex-col ui:justify-center ui:flex-1">
<Card.Title class="ui:text-2xl ui:font-bold ui:mb-2">{course.title}</Card.Title>
<Card.Description class="ui:mb-6 ui:line-clamp-2">{course.description}</Card.Description>
<div class="ui:flex ui:items-center ui:gap-4 ui:text-sm ui:font-semibold ui:mb-6">
{#if course.duration}
<div
class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<ClockIcon class="ui:size-3.5" />
<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-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] 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-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-2.5 ui:py-1 ui:rounded-md"
>
<TagIcon class="ui:size-3.5 ui:text-[var(--landing-accent)]" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
</div>
<div class="ui:mt-auto ui:flex ui:items-center ui:justify-between">
<span class="ui:font-black ui:text-lg">{course.price || formatCurrency(course.cost, course.currency)}</span>
<Button
href={disableCourseLinks ? undefined : course.link}
variant="outline"
class="ui:rounded-lg ui:font-bold ui:text-sm {disableCourseLinks
? 'ui:pointer-events-none'
: 'ui:hover:bg-[var(--landing-accent)] ui:hover:text-[var(--landing-accent-fg)] ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)] ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{labels?.enrollLabel ?? 'Details'}
</Button>
</div>
</Card.Content>
</Card.Root>
@@ -2,7 +2,6 @@
import type { OrgLandingPageProps } from '../types';
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 {
@@ -15,35 +14,27 @@
<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>
<h1 class="ui:text-5xl ui:lg:text-7xl ui:font-black ui:leading-[1.1] ui:tracking-tighter">{hero.heading}</h1>
<p class="ui:text-xl ui:text-[var(--landing-fg-muted)] ui:mb-10 ui:leading-relaxed">{hero.subheading}</p>
<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-[var(--landing-accent)]/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>
</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>
<div
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent)]/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" />
{/if}
</section>
</EditableLandingSection>
@@ -17,11 +17,13 @@
</script>
<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">
<header
class="ui:sticky ui:top-0 ui:z-10 ui:bg-[var(--landing-bg)]/90 ui:backdrop-blur-xl ui:border-b ui:border-[var(--landing-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"
class="ui:font-black ui:text-2xl ui:tracking-tighter ui:flex ui:items-center ui:gap-3 ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
@@ -33,7 +35,7 @@
{#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"
class="ui:text-sm ui:font-bold ui:uppercase ui:tracking-widest ui:hover:text-[var(--landing-accent)] ui:transition-colors ui:cursor-pointer"
>{item.label}</a
>
{/each}
@@ -1,21 +1,43 @@
<script lang="ts">
import type { LandingPageCallout, OrgLandingPageTheme } from './types';
import type { LandingPageCallout, OrgLandingPageLabels, 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?: OrgLandingPageTheme } = $props();
let {
callout,
variant = 'minimal',
labels
}: {
callout?: LandingPageCallout;
variant?: OrgLandingPageTheme;
labels?: OrgLandingPageLabels;
} = $props();
/** Per-variant default eyebrow used when labels.calloutEyebrow is not provided. */
const defaultEyebrow = $derived<string | null>(
variant === 'saas'
? 'Get started'
: variant === 'tech'
? '// get started'
: variant === 'studio' || variant === 'corporate' || variant === 'editorial'
? 'Ready when you are'
: null
);
const resolvedEyebrow = $derived(labels?.calloutEyebrow ?? defaultEyebrow);
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',
bold: 'ui:py-24 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
classic: 'ui:py-20 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
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',
tech: 'ui:py-24 ui:px-6 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
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]'
'ui:py-32 ui:px-6 ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]',
corporate:
'ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]',
terminal: 'ui:py-20 ui:px-6 ui:border-t ui:border-[#1c1f28] ui:text-[#e9eaed] ui:bg-[#06070a]',
editorial: 'ui:py-32 ui:md:py-40 ui:px-6 ui:bg-[#e2e1d9] ui:text-[#1a1a1a]',
vibrant: 'ui:py-32 ui:md:py-40 ui:px-6 ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:text-center'
};
const headingClasses: Record<string, string> = {
@@ -26,59 +48,61 @@
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'
terminal: 'ui:text-[22px] ui:font-semibold ui:tracking-tight',
editorial: 'ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:leading-[1.05]',
vibrant: 'ui:text-5xl ui:md:text-6xl ui:lg:text-[80px] ui:font-medium ui:tracking-tight ui:leading-[1.02]'
};
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',
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]'
minimal: 'ui:text-lg ui:text-[var(--landing-fg)]/70',
bold: 'ui:text-xl ui:text-[var(--landing-bg)]/70',
classic: 'ui:text-lg ui:text-[var(--landing-bg)]/80',
saas: 'ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
tech: 'ui:text-lg ui:text-[var(--landing-bg)]/70 ui:font-mono',
studio: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
corporate: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto',
terminal: 'ui:text-sm ui:max-w-md ui:mx-auto ui:text-[#9da1ab]',
editorial: 'ui:text-base ui:md:text-lg ui:text-[#76746c] ui:max-w-xl ui:mx-auto',
vibrant: 'ui:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto'
};
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',
bold: 'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:shadow-lg ui:shadow-[var(--landing-accent)]/20',
classic: 'ui:px-8 ui:font-semibold',
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-primary/20',
saas: 'ui:rounded-full ui:px-7 ui:font-semibold ui:shadow-md ui:shadow-[var(--landing-accent)]/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',
studio: 'ui:rounded-md ui:px-5 ui:font-medium ui:shadow-lg ui:shadow-[var(--landing-accent)]/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]'
'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]',
editorial:
'ui:rounded-full ui:px-6 ui:font-medium ui:bg-[#1a1a1a] ui:text-[#fafaf5] ui:hover:bg-[#2c2b29] ui:transition-colors',
vibrant:
'ui:rounded-md ui:px-7 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:bg-[var(--landing-accent)]/90 ui:transition-colors'
};
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: ''
saas: 'ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-1',
tech: 'ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-bg)]/70 ui:mb-1',
studio: 'ui:text-sm ui:text-[var(--landing-fg-muted)] 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-[var(--landing-fg)] ui:mb-1',
terminal: '',
editorial: 'ui:text-sm ui:text-[#76746c] ui:mb-1',
vibrant: ''
};
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
};
// Default eyebrow per variant is computed above as `defaultEyebrow`, resolved
// into `resolvedEyebrow` (which prefers labels.calloutEyebrow when provided).
const minimalBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 10%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 4%, var(--background)) 100%
color-mix(in oklab, var(--landing-accent) 10%, var(--landing-bg)) 0%,
color-mix(in oklab, var(--landing-accent) 4%, var(--landing-bg)) 100%
)
`;
</script>
@@ -93,7 +117,7 @@
<!-- 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);"
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--landing-accent) 28%, transparent) 0%, transparent 60%); filter: blur(40px);"
aria-hidden="true"
></div>
{/if}
@@ -101,45 +125,37 @@
<!-- 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%);"
style="background: radial-gradient(ellipse at center, color-mix(in oklab, var(--landing-accent) 14%, transparent) 0%, transparent 60%);"
aria-hidden="true"
></div>
{/if}
<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 resolvedEyebrow}
{#if variant === 'studio'}
<p class={eyebrowClasses[variant]}>
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
{resolvedEyebrow}
</p>
{:else}
<p class={eyebrowClasses[variant]}>{resolvedEyebrow}</p>
{/if}
{/if}
<BlurFade delay={0.05} once={true}>
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
</BlurFade>
<h2 class={headingClasses[variant]}>{callout.heading}</h2>
{#if callout.description}
<BlurFade delay={0.15} once={true}>
<p class={descriptionClasses[variant]}>{callout.description}</p>
</BlurFade>
<p class={descriptionClasses[variant]}>{callout.description}</p>
{/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>
<Button
href={callout.action.href}
size="lg"
class={buttonClasses[variant]}
variant={variant === 'bold' || variant === 'classic' || variant === 'tech' ? 'secondary' : 'default'}
>
{callout.action.label}
</Button>
</div>
</section>
</EditableLandingSection>
@@ -6,12 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
import ClassicHero from './classic/hero.svelte';
import ClassicCourseCard from './classic/course-card.svelte';
import { Button } from '../../base/button';
import * as Item from '../../base/item';
import { BlurFade } from '../animation/blurfade';
import { CourseCard } from '../course-card';
import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from './landing-page-utils';
import { cn } from '../../tools';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -25,11 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: OrgLandingPageProps = $props();
</script>
<div class="ui:min-h-screen ui:bg-muted/10 ui:text-foreground ui:font-sans">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('classic')}
>
<ClassicNav {orgName} {logoUrl} {navItems} {authAction} />
<main>
@@ -37,93 +38,15 @@
<section class="ui:py-10 ui:px-6 ui:lg:px-8 ui:max-w-7xl ui:mx-auto">
<div class="ui:text-center ui:mb-8">
<BlurFade delay={0} once={true}>
<h2 class="ui:text-3xl ui:font-bold ui:text-foreground">Featured Programs</h2>
</BlurFade>
<BlurFade delay={0.15} once={true}>
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-border ui:mx-auto"></div>
</BlurFade>
<h2 class="ui:text-3xl ui:font-bold ui:text-[var(--landing-fg)]">
{labels?.catalogHeading ?? 'Featured Programs'}
</h2>
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div>
</div>
<div class="ui:grid ui:grid-cols-1 ui:sm:grid-cols-2 ui:lg:grid-cols-3 ui:gap-8 ui:justify-items-center">
{#each courses as course, index (course.id)}
{@const courseTypeMeta = getCourseTypeLandingMeta(course)}
{@const pricingData = {
cost: course.cost ?? 0,
discount: course.metadata?.discount ?? 0,
showDiscount: !!course.metadata?.showDiscount
}}
{@const displayCost = calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount)}
{@const currencyFormatter = new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD'
})}
<BlurFade delay={0.1 * index} once={true} class="ui:w-full ui:flex ui:justify-center">
<CourseCard
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
bannerImage={course.logo || ''}
bannerAlt={course.title}
title={course.title}
description={course.description}
typeBadge={courseTypeMeta
? {
label: courseTypeMeta.label,
icon: courseTypeMeta.icon,
iconClass: courseTypeMeta.iconClass
}
: undefined}
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:hover:shadow-md ui:transition-shadow')}
>
{#snippet tags()}
{#if course.tags && course.tags.length > 0}
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
{#each course.tags as tag (tag.id)}
<Item.SubDescription
class="ui:border-border ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
>
<span
class="ui:bg-primary/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="ui:flex ui:justify-between ui:w-full">
<div class="ui:w-[80%]">
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
<span>{course.lessonCount || 0} lessons</span>
&
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
</p>
<div class="ui:py-2 ui:text-xs">
{#if course.price != null && String(course.price).length > 0}
<span class="ui:px-2">{course.price}</span>
{:else if !displayCost}
<span class="ui:px-2">Free</span>
{:else if pricingData.showDiscount}
<span class="ui:px-2">
{currencyFormatter.format(displayCost)}
<span class="line-through">
{currencyFormatter.format(pricingData.cost)}
</span>
</span>
{:else}
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
{/if}
</div>
</div>
</div>
{/snippet}
</CourseCard>
</BlurFade>
<ClassicCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
@@ -136,18 +59,18 @@
class="ui:px-8"
disabled={disableCourseLinks}
>
View more courses
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
</section>
</main>
<OrgLandingPageLinks {links} variant="classic" />
<OrgLandingPageLinks {links} {labels} variant="classic" />
<OrgLandingPageEmbed {embed} variant="classic" />
<OrgLandingPageEmbed {embed} {labels} variant="classic" />
<OrgLandingPageCallout {callout} variant="classic" />
<OrgLandingPageCallout {callout} {labels} variant="classic" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
</div>
@@ -0,0 +1,97 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import * as Item from '../../../base/item';
import { CourseCard } from '../../course-card';
import { calcCourseDiscount, formatExerciseCountLabel, getCourseTypeLandingMeta } from '../landing-page-utils';
import { cn } from '../../../tools';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const pricingData = $derived({
cost: course.cost ?? 0,
discount: course.metadata?.discount ?? 0,
showDiscount: !!course.metadata?.showDiscount
});
const displayCost = $derived(calcCourseDiscount(pricingData.discount, pricingData.cost, pricingData.showDiscount));
const currencyFormatter = $derived(
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: course.currency ?? 'USD'
})
);
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
</script>
<CourseCard
{href}
bannerImage={course.logo || ''}
bannerAlt={course.title}
title={course.title}
description={course.description}
typeBadge={courseTypeMeta
? {
label: courseTypeMeta.label,
icon: courseTypeMeta.icon,
iconClass: courseTypeMeta.iconClass
}
: undefined}
class={cn('ui:group ui:relative', !disableCourseLinks && 'ui:cursor-pointer ui:hover:shadow-md ui:transition-shadow')}
>
{#snippet tags()}
{#if course.tags && course.tags.length > 0}
<div class="ui:flex ui:flex-wrap ui:gap-1 ui:min-w-0">
{#each course.tags as tag (tag.id)}
<Item.SubDescription
class="ui:border-[var(--landing-border)] ui:inline-flex ui:shrink-0 ui:items-center ui:gap-1.5 ui:rounded-full ui:border ui:px-2"
>
<span
class="ui:bg-[var(--landing-accent)]/60 ui:inline-block ui:h-1.5 ui:w-1.5 ui:shrink-0 ui:rounded-full"
style={tag.color ? `background-color: ${tag.color}` : undefined}
aria-hidden="true"
></span>
<span>{tag.name}</span>
</Item.SubDescription>
{/each}
</div>
{/if}
{/snippet}
{#snippet footer()}
<div class="ui:flex ui:justify-between ui:w-full">
<div class="ui:w-[80%]">
<p class="ui:text-xs ui:pl-2 ui:dark:text-white">
<span>{course.lessonCount || 0} lessons</span>
&
<span>{formatExerciseCountLabel(course.exerciseCount ?? 0)}</span>
</p>
<div class="ui:py-2 ui:text-xs">
{#if course.price != null && String(course.price).length > 0}
<span class="ui:px-2">{course.price}</span>
{:else if !displayCost}
<span class="ui:px-2">{labels?.freeLabel ?? 'Free'}</span>
{:else if pricingData.showDiscount}
<span class="ui:px-2">
{currencyFormatter.format(displayCost)}
<span class="line-through">
{currencyFormatter.format(pricingData.cost)}
</span>
</span>
{:else}
<span class="ui:px-2">{currencyFormatter.format(displayCost)}</span>
{/if}
</div>
</div>
</div>
{/snippet}
</CourseCard>
@@ -2,7 +2,6 @@
import type { OrgLandingPageProps } from '../types';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { BlurIn } from '../../animation/blurin';
import { DotPattern } from '../../animation/dot-pattern';
import EditableLandingSection from '../editable-section.svelte';
@@ -16,7 +15,7 @@
<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"
class="ui:relative ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] 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">
@@ -30,28 +29,24 @@
>
{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>
<p class="ui:text-lg ui:sm:text-xl ui:text-[var(--landing-bg)]/80 ui:mb-10">{hero.subheading}</p>
<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>
</div>
</section>
</EditableLandingSection>
@@ -17,9 +17,12 @@
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:bg-foreground ui:text-background">
<header class="ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]">
<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">
<a
href="/"
class="ui:flex ui:items-center ui:gap-3 ui:text-xl ui:font-bold ui:tracking-wide ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
@@ -30,7 +33,7 @@
{#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"
class="ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] ui:transition-colors ui:cursor-pointer"
>{item.label}</a
>
{/each}
@@ -39,7 +42,7 @@
<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"
class="ui:bg-transparent ui:text-[var(--landing-bg)] ui:border-[var(--landing-bg)]/40 ui:hover:bg-[var(--landing-bg)]/10 ui:hover:text-[var(--landing-bg)] ui:hover:border-[var(--landing-bg)]/60"
>
{authAction.label}
</Button>
@@ -6,9 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import CorporateNav from './corporate/nav.svelte';
import CorporateHero from './corporate/hero.svelte';
import CorporateCourseCard from './corporate/course-card.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import { getCourseTypeLandingMeta } from './landing-page-utils';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -22,16 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: 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">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('corporate')}
>
<main>
<CorporateHero {orgName} {hero}>
{#snippet navigation()}
@@ -40,15 +39,17 @@
</CorporateHero>
{#if courses.length > 0}
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-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
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3"
>
{labels?.catalogEyebrow ?? '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
{labels?.catalogHeading ?? 'Programs your teams are taking'}
</h2>
</div>
{#if hasMoreCourses}
@@ -58,50 +59,16 @@
class="ui:rounded-none ui:font-medium"
disabled={disableCourseLinks}
>
Browse catalog →
{labels?.browseCoursesLabel ?? '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"
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-[var(--landing-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>
<CorporateCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
</div>
@@ -109,11 +76,11 @@
{/if}
</main>
<OrgLandingPageLinks {links} variant="corporate" />
<OrgLandingPageLinks {links} {labels} variant="corporate" />
<OrgLandingPageEmbed {embed} variant="corporate" />
<OrgLandingPageEmbed {embed} {labels} variant="corporate" />
<OrgLandingPageCallout {callout} variant="corporate" />
<OrgLandingPageCallout {callout} {labels} variant="corporate" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<a
{href}
class="ui:flex ui:flex-col ui:h-full ui:p-6 ui:gap-2 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/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-[var(--landing-fg-muted)] 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-[var(--landing-fg-muted)] 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-[var(--landing-border)]">
<span class="ui:text-sm ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</span>
<span class="ui:text-sm ui:text-[var(--landing-fg)] ui:underline ui:underline-offset-4" aria-hidden="true">
{labels?.enrollLabel ?? 'Enroll →'}
</span>
</div>
</a>
@@ -3,7 +3,6 @@
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 {
@@ -22,85 +21,79 @@
<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:flex ui:items-center ui:gap-4 ui:pb-5 ui:mb-10 ui:border-b ui:border-[var(--landing-border)]">
<span class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg)]">
{orgName}
</span>
<span class="ui:h-px ui:flex-1 ui:bg-[var(--landing-border)]" aria-hidden="true"></span>
<span
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg-muted)]"
>
Learning Report
</span>
</div>
<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"
<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-[var(--landing-fg)]"
>
{hero.heading}
</h1>
<p
class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mb-8 ui:leading-relaxed"
>
{hero.subheading}
</p>
<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.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>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="corporate"
/>
{/if}
</div>
</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>
<p
class="ui:text-[11px] ui:font-semibold ui:tracking-[0.2em] ui:uppercase ui:text-[var(--landing-fg-muted)] 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>
<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-[var(--landing-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-[var(--landing-fg-muted)] ui:tabular-nums">
{String(index + 1).padStart(2, '0')}
</span>
<dt class="ui:text-sm ui:text-[var(--landing-fg)] ui:truncate">
{stat.label}
</dt>
</div>
{/each}
</dl>
</BlurFade>
<dd
class="ui:text-2xl ui:md:text-3xl ui:font-semibold ui:tracking-tight ui:tabular-nums ui:text-[var(--landing-fg)] ui:m-0"
>
{stat.value}
</dd>
</div>
{/each}
</dl>
</aside>
{/if}
</div>
@@ -17,12 +17,14 @@
</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">
<header
class="ui:sticky ui:top-0 ui:z-50 ui:bg-[var(--landing-bg)]/95 ui:backdrop-blur-md ui:border-b ui:border-[var(--landing-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"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
@@ -35,7 +37,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"
class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:no-underline ui:cursor-pointer"
>
{item.label}
</a>
@@ -0,0 +1,184 @@
<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 EditorialNav from './editorial/nav.svelte';
import EditorialHero from './editorial/hero.svelte';
import EditorialCourseCard from './editorial/course-card.svelte';
import { themeStyle } from './theme-style';
import { Button } from '../../base/button';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer,
labels
}: OrgLandingPageProps = $props();
function formatCost(cost?: number, currency = 'USD'): string {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
}
type FilterKey = 'ALL' | 'LIVE_CLASS' | 'SELF_PACED' | 'COMPLIANCE' | 'PUBLIC';
const filterDefs: Array<{ key: FilterKey; label: string }> = $derived([
{ key: 'ALL', label: labels?.filterAllLabel ?? 'All' },
{
key: 'LIVE_CLASS',
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'LIVE_CLASS' })?.label ?? 'Live class'
},
{
key: 'SELF_PACED',
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'SELF_PACED' })?.label ?? 'Self-paced'
},
{
key: 'COMPLIANCE',
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'COMPLIANCE' })?.label ?? 'Compliance'
},
{
key: 'PUBLIC',
label: getCourseTypeLandingMeta({ id: '', title: '', description: '', type: 'PUBLIC' })?.label ?? 'Public'
}
]);
let activeFilter: FilterKey = $state('ALL');
const filterCounts = $derived.by(() => {
const counts: Record<FilterKey, number> = {
ALL: courses.length,
LIVE_CLASS: 0,
SELF_PACED: 0,
COMPLIANCE: 0,
PUBLIC: 0
};
for (const course of courses) {
const t = course.type?.trim().toUpperCase();
if (t === 'LIVE_CLASS' || t === 'SELF_PACED' || t === 'COMPLIANCE' || t === 'PUBLIC') {
counts[t]++;
}
}
return counts;
});
const visibleFilterDefs = $derived(filterDefs.filter((f) => f.key === 'ALL' || filterCounts[f.key] > 0));
const filteredCourses = $derived(
activeFilter === 'ALL' ? courses : courses.filter((c) => c.type?.trim().toUpperCase() === activeFilter)
);
function courseHref(course: CourseItem): string | undefined {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
}
</script>
<div
class="ui:min-h-screen ui:font-sans ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)]"
style={themeStyle('editorial')}
>
<main>
<EditorialHero {hero} {courses} {labels}>
{#snippet navigation()}
<EditorialNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</EditorialHero>
{#if courses.length > 0}
<section id="courses" class="ui:py-10 ui:px-6 ui:md:px-8 ui:bg-[var(--landing-bg)]">
<div class="ui:max-w-[1240px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-8">
<div>
<p class="ui:text-[13px] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg-muted)]">
{labels?.catalogEyebrow ?? 'Catalog'}
</p>
<h2
class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:leading-[1.1] ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.025em;"
>
{labels?.catalogHeading ?? 'Programs starting this season.'}
</h2>
<p class="ui:text-base ui:mt-3 ui:max-w-xl ui:m-0 ui:text-[var(--landing-fg-muted)]">
{labels?.catalogDescription ??
'Cohort-based and self-paced — every program ends with a reviewed project and a certificate of completion.'}
</p>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-bg-section)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all courses →'}
</Button>
{/if}
</div>
{#if visibleFilterDefs.length > 1}
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-1.5 ui:mb-8">
{#each visibleFilterDefs as def (def.key)}
{@const isActive = activeFilter === def.key}
<button
type="button"
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:px-3.5 ui:py-1.5 ui:rounded-full ui:text-[13.5px] ui:transition-colors ui:border ui:cursor-pointer"
style={isActive
? 'background: var(--landing-fg); color: var(--landing-bg); border-color: var(--landing-fg);'
: 'background: transparent; color: var(--landing-fg); border-color: var(--landing-border);'}
onclick={() => (activeFilter = def.key)}
>
{def.label}
<span
class="ui:text-[11.5px] ui:tabular-nums"
style={isActive ? 'opacity: 0.6;' : 'color: var(--landing-fg-faint);'}
>
{filterCounts[def.key]}
</span>
</button>
{/each}
</div>
{/if}
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-4">
{#each filteredCourses as course, index (course.id)}
<EditorialCourseCard {course} {index} {disableCourseLinks} {labels} />
{/each}
</div>
{#if hasMoreCourses}
<div class="ui:text-center ui:mt-10">
<Button
href={disableCourseLinks ? undefined : '/courses'}
size="lg"
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'View all courses'}
</Button>
</div>
{/if}
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} {labels} variant="editorial" />
<OrgLandingPageEmbed {embed} {labels} variant="editorial" />
<OrgLandingPageCallout {callout} {labels} variant="editorial" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="editorial" />
</div>
@@ -0,0 +1,122 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import { defaultEnrolledLabel, defaultLessonsLabel, getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
/** Index in the parent grid; used to cycle the painterly thumb gradient when the course has no logo. */
index?: number;
}
let { course, disableCourseLinks = false, labels, index = 0 }: Props = $props();
const thumbGradients = [
'radial-gradient(60% 80% at 30% 30%, #c4b78b 0%, transparent 60%), linear-gradient(135deg, #8a9460 0%, #c4a878 100%)',
'radial-gradient(50% 70% at 70% 40%, #d4a48a 0%, transparent 60%), linear-gradient(135deg, #6f5a4a 0%, #b08868 100%)',
'radial-gradient(60% 80% at 30% 30%, #8aa4b8 0%, transparent 60%), linear-gradient(135deg, #3a4858 0%, #6a7e92 100%)',
'radial-gradient(60% 80% at 40% 60%, #b8a4a4 0%, transparent 60%), linear-gradient(135deg, #5e4848 0%, #a87a78 100%)',
'radial-gradient(60% 80% at 50% 50%, #c4c08a 0%, transparent 60%), linear-gradient(135deg, #6a6840 0%, #a8a468 100%)',
'radial-gradient(60% 80% at 30% 30%, #aab8c4 0%, transparent 60%), linear-gradient(135deg, #4a5868 0%, #788a9a 100%)'
];
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const resolvedLessonsLabel = $derived(labels?.lessonsLabel ?? defaultLessonsLabel);
const resolvedEnrolledLabel = $derived(labels?.enrolledLabel ?? defaultEnrolledLabel);
const thumbBg = $derived(
course.logo ? `center / cover no-repeat url('${course.logo}')` : thumbGradients[index % thumbGradients.length]
);
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCost(cost?: number, currency = 'USD'): string {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
}
</script>
<a
{href}
class="ui:flex ui:flex-col ui:h-full ui:overflow-hidden ui:rounded-lg ui:no-underline ui:transition-transform ui:hover:-translate-y-0.5 ui:bg-[var(--landing-card-soft)] ui:text-[var(--landing-fg)] {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<div class="ui:relative ui:h-[168px] ui:overflow-hidden" style="background: {thumbBg};">
<div
class="ui:pointer-events-none ui:absolute ui:inset-0"
style="background-image: repeating-linear-gradient(45deg, rgba(0,0,0,0.05) 0px, rgba(0,0,0,0.05) 1px, transparent 1px, transparent 5px); mix-blend-mode: overlay;"
aria-hidden="true"
></div>
{#if courseTypeMeta}
<span
class="ui:absolute ui:top-3.5 ui:left-3.5 ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11px] ui:font-medium ui:text-[var(--landing-fg)]"
style="background: color-mix(in srgb, var(--landing-card) 95%, transparent);"
>
{courseTypeMeta.label}
</span>
{/if}
{#if course.duration}
<span
class="ui:absolute ui:bottom-3.5 ui:right-3.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11px] ui:tabular-nums"
style="background: color-mix(in srgb, var(--landing-fg) 70%, transparent); color: var(--landing-bg);"
>
{course.duration}
</span>
{/if}
</div>
<div class="ui:flex ui:flex-col ui:flex-1 ui:p-5 ui:md:p-6">
<h3
class="ui:text-[19px] ui:font-medium ui:tracking-tight ui:leading-[1.25] ui:m-0 ui:mb-2 ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.015em;"
>
{course.title}
</h3>
<p class="ui:text-sm ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-4 ui:flex-1 ui:text-[var(--landing-fg-muted)]">
{course.description}
</p>
{#if course.lessonCount || course.totalStudents || course.level}
<div
class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-3 ui:gap-y-1 ui:text-[12.5px] ui:mb-4 ui:text-[var(--landing-fg-muted)]"
>
{#if course.lessonCount}
<span>{resolvedLessonsLabel(course.lessonCount)}</span>
{/if}
{#if course.level}
{#if course.lessonCount}
<span aria-hidden="true">·</span>
{/if}
<span>{course.level}</span>
{/if}
{#if course.totalStudents}
{#if course.lessonCount || course.level}
<span aria-hidden="true">·</span>
{/if}
<span>{resolvedEnrolledLabel(course.totalStudents)}</span>
{/if}
</div>
{/if}
<div
class="ui:flex ui:items-center ui:justify-between ui:pt-4 ui:mt-auto"
style="border-top: 1px solid var(--landing-border);"
>
<span class="ui:text-base ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
{course.price || formatCost(course.cost, course.currency)}
</span>
<span
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)]"
aria-hidden="true"
>
{labels?.enrollLabel ?? 'Enroll'} <span>→</span>
</span>
</div>
</div>
</a>
@@ -0,0 +1,185 @@
<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 EditableLandingSection from '../editable-section.svelte';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
labels?: OrgLandingPageProps['labels'];
navigation: Snippet;
}
let { hero, courses = [], labels, navigation }: Props = $props();
const featured = $derived(courses[0]);
const featuredTypeMeta = $derived(featured ? getCourseTypeLandingMeta(featured) : undefined);
const syllabusItems = $derived(
featured
? [
{ label: 'Foundations', duration: '42 min' },
{ label: 'Core concepts', duration: '1h 12m' },
{ label: 'Hands-on practice', duration: '58 min' },
{ label: 'Capstone project', duration: '2h 30m' }
]
: []
);
function formatCost(cost?: number, currency = 'USD'): string {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
}
</script>
{@render navigation()}
<EditableLandingSection sectionKey="hero">
<section class="ui:px-6 ui:md:px-8 ui:pt-16 ui:md:pt-20 ui:pb-12 ui:bg-[var(--landing-bg)]">
<div class="ui:max-w-[1240px] ui:mx-auto">
<div
class="ui:grid ui:grid-cols-1 ui:gap-12 ui:lg:gap-14 ui:items-start {featured
? 'ui:lg:grid-cols-[1fr_1.05fr]'
: ''}"
>
<div class={featured ? '' : 'ui:text-center'}>
{#if hero.stats && hero.stats.length > 0}
<p class="ui:text-[13px] ui:mb-5 ui:text-[var(--landing-fg-muted)]">
{hero.stats[0].label}
{#if hero.stats[0].value}
<span class="ui:mx-2" aria-hidden="true">·</span>
<span class="ui:text-[var(--landing-fg)]">{hero.stats[0].value}</span>
{/if}
</p>
{/if}
<h1
class="ui:text-[44px] ui:md:text-[56px] ui:lg:text-[64px] ui:font-medium ui:tracking-tight ui:leading-[1.02] ui:m-0 ui:mb-5 ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.03em;"
>
{hero.heading}
</h1>
<p
class="ui:text-lg ui:md:text-xl ui:leading-[1.35] ui:max-w-xl ui:m-0 ui:mb-9 ui:text-[var(--landing-fg-muted)] {featured
? ''
: 'ui:mx-auto'}"
style="letter-spacing: -0.012em;"
>
{hero.subheading}
</p>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-2.5 {featured ? '' : 'ui:justify-center'}">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="lg"
class="ui:rounded-full ui:px-6 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="editorial"
/>
{/if}
</div>
</div>
{#if featured}
{@const cost = featured.cost ?? 0}
{@const seats = featured.totalStudents ?? 0}
<div
class="ui:relative ui:overflow-hidden ui:rounded-xl"
style="
height: 480px;
background:
radial-gradient(60% 80% at 20% 30%, #b8a877 0%, transparent 60%),
radial-gradient(50% 60% at 80% 70%, #6b7a4a 0%, transparent 65%),
radial-gradient(40% 50% at 50% 50%, #d4c89a 0%, transparent 70%),
linear-gradient(135deg, #8a9460 0%, #c4a878 50%, #8b7a4f 100%);
"
>
<div
class="ui:pointer-events-none ui:absolute ui:inset-0"
style="
background-image:
repeating-linear-gradient(45deg, rgba(0,0,0,0.06) 0px, rgba(0,0,0,0.06) 1px, transparent 1px, transparent 4px),
repeating-linear-gradient(-45deg, rgba(255,255,255,0.05) 0px, rgba(255,255,255,0.05) 1px, transparent 1px, transparent 6px);
mix-blend-mode: overlay;
"
aria-hidden="true"
></div>
<div
class="ui:absolute ui:left-1/2 ui:top-1/2 ui:-translate-x-1/2 ui:-translate-y-1/2 ui:w-[84%] ui:overflow-hidden ui:rounded-[10px] ui:bg-[var(--landing-card)]"
style="
border: 1px solid var(--landing-border);
box-shadow: 0 24px 60px -20px rgba(0,0,0,0.28);
"
>
<div class="ui:flex ui:items-center ui:gap-2 ui:px-5 ui:pt-4 ui:text-[var(--landing-fg-muted)]">
<span
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-2.5 ui:py-0.5 ui:text-[11.5px] ui:font-medium ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg)]"
>
{featuredTypeMeta?.label ?? labels?.featuredLabel ?? 'Featured'}
</span>
{#if featured.duration}
<span class="ui:text-[12px]" aria-hidden="true">·</span>
<span class="ui:text-[12px]">{featured.duration}</span>
{/if}
</div>
<h3
class="ui:m-0 ui:mx-5 ui:mt-2 ui:mb-2 ui:text-[22px] ui:font-medium ui:tracking-tight ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.02em;"
>
{featured.title}
</h3>
<p
class="ui:m-0 ui:mx-5 ui:mb-4 ui:text-[13px] ui:line-clamp-2 ui:text-[var(--landing-fg-muted)]"
style="line-height: 1.5;"
>
{featured.description}
</p>
<div class="ui:px-5 ui:pb-4">
{#each syllabusItems as item, idx (idx)}
<div
class="ui:flex ui:items-center ui:gap-2.5 ui:py-1.5 ui:text-[13px]"
style={idx === 0 ? '' : 'border-top: 1px solid var(--landing-border-soft);'}
>
<span class="ui:text-[11px] ui:w-4 ui:tabular-nums ui:text-[var(--landing-fg-faint)]">
{String(idx + 1).padStart(2, '0')}
</span>
<span class="ui:flex-1 ui:text-[var(--landing-fg)]">{item.label}</span>
<span class="ui:text-[11.5px] ui:tabular-nums ui:text-[var(--landing-fg-muted)]"
>{item.duration}</span
>
</div>
{/each}
</div>
<div
class="ui:flex ui:items-center ui:justify-between ui:px-5 ui:py-3 ui:bg-[var(--landing-bg)]"
style="border-top: 1px solid var(--landing-border-soft);"
>
<span class="ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
{formatCost(cost, featured.currency)}
</span>
{#if seats > 0}
<span class="ui:text-[12.5px] ui:text-[var(--landing-fg-muted)]"
>{(labels?.enrolledLabel ?? ((n: number) => `${n.toLocaleString()} enrolled`))(seats)}</span
>
{/if}
</div>
</div>
</div>
{/if}
</div>
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,63 @@
<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:backdrop-blur-md"
style="background: color-mix(in srgb, var(--landing-bg) 85%, transparent); border-bottom: 1px solid var(--landing-border-soft);"
>
<div class="ui:max-w-[1240px] ui:mx-auto ui:px-6 ui:md:px-8">
<div class="ui:flex ui:items-center ui:justify-between ui:h-16">
<a
href="/"
class="ui:flex ui:items-center ui:gap-2.5 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:no-underline ui:text-[var(--landing-fg)] ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
{/if}
<span>{orgName}</span>
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-8">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:text-sm ui:transition-colors ui:no-underline ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:cursor-pointer"
>
{item.label}
</a>
{/each}
</nav>
{/if}
<div class="ui:flex ui:items-center ui:gap-2.5">
{#if authAction}
<Button
href={authAction.href}
size="sm"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:hover:opacity-90"
>
{authAction.label}
</Button>
{/if}
</div>
</div>
</div>
</header>
</EditableLandingSection>
@@ -1,10 +1,17 @@
<script lang="ts">
import type { OrgLandingPageTheme, LandingPageEmbed } from './types';
import type { OrgLandingPageLabels, 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();
let {
embed,
variant = 'minimal',
labels
}: {
embed?: LandingPageEmbed;
variant?: OrgLandingPageTheme;
labels?: OrgLandingPageLabels;
} = $props();
</script>
{#if embed}
@@ -12,82 +19,84 @@
{#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>
<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(--landing-accent);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
></span>
{labels?.embedEyebrow ?? 'Embed'}
</p>
<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>
{#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>
<p class="ui:max-w-xl ui:m-0 ui:mb-6 ui:text-[15px]" style="color: #9da1ab;">
{embed.description}
</p>
{/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>
<div class="ui:mb-8">
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
</div>
{/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
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>
</BlurFade>
</div>
{/if}
</div>
</section>
{:else if variant === 'editorial'}
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:md:px-8 ui:bg-[#f4f3ed]">
<div class="ui:mx-auto ui:max-w-[1240px] ui:flex ui:flex-col ui:items-center ui:gap-6 ui:text-center">
<h3
class="ui:text-3xl ui:md:text-[40px] ui:font-medium ui:tracking-tight ui:m-0 ui:text-[#1a1a1a]"
style="line-height: 1.1; letter-spacing: -0.025em;"
>
{embed.title}
</h3>
{#if embed.description?.trim()}
<p class="ui:max-w-xl ui:text-center ui:text-base ui:leading-relaxed ui:text-[#76746c] ui:m-0">
{embed.description}
</p>
{/if}
{#if embed.secondaryAction?.label && embed.secondaryAction?.href}
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
{/if}
{#if embed.code}
<div class="ui:flex ui:w-full ui:justify-center">
{@html embed.code}
</div>
{/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>
<h3 class="ui:text-center ui:text-2xl ui:font-semibold ui:tracking-tight">{embed.title}</h3>
{#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>
<p class="ui:max-w-3xl ui:text-center ui:text-base ui:leading-7 ui:text-[var(--landing-fg-muted)]">
{embed.description}
</p>
{/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>
<SecondaryActionButton href={embed.secondaryAction.href} label={embed.secondaryAction.label} {variant} />
{/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>
<div class="ui:flex ui:w-full ui:justify-center">
{@html embed.code}
</div>
{/if}
</div>
</section>
@@ -1,6 +1,6 @@
import type { OrgLandingPageProps } from '@cio/ui/custom/org-landing-page/types';
import type { OrgLandingPageProps } from './types';
export const mockProps: OrgLandingPageProps = {
export const mockOrgLandingPageProps: OrgLandingPageProps = {
orgName: 'Certifi Academy',
logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff',
navItems: [
@@ -154,25 +154,100 @@ export const mockProps: OrgLandingPageProps = {
brand: {
socials: [
{ platform: 'linkedin', href: 'https://linkedin.com/company/example' },
{ platform: 'x', href: 'https://x.com/example' }
{ platform: 'x', href: 'https://x.com/example' },
{ platform: 'youtube', href: 'https://youtube.com/@example' },
{ platform: 'github', href: 'https://github.com/example' },
{ platform: 'instagram', href: 'https://instagram.com/example' },
{ platform: 'facebook', href: 'https://facebook.com/example' },
{ platform: 'tiktok', href: 'https://tiktok.com/@example' },
{ platform: 'website', href: 'https://example.com' }
]
},
columns: [
{
id: 'col-company',
heading: 'Company',
id: 'col-product',
heading: 'Product',
links: [
{ id: 'col-company-about', label: 'About', href: '#about' },
{ id: 'col-company-careers', label: 'Careers', href: '#careers' }
],
cta: { label: 'Explore more', href: '#more' }
{ id: 'col-product-overview', label: 'Overview', href: '#overview' },
{ id: 'col-product-features', label: 'Features', href: '#features' },
{ id: 'col-product-integrations', label: 'Integrations', href: '#integrations' },
{ id: 'col-product-automations', label: 'Automations', href: '#automations' },
{ id: 'col-product-certifications', label: 'Certifications', href: '#certifications' },
{ id: 'col-product-analytics', label: 'Analytics', href: '#analytics' },
{ id: 'col-product-mobile', label: 'Mobile app', href: '#mobile' },
{ id: 'col-product-changelog', label: 'Changelog', href: '#changelog' },
{ id: 'col-product-roadmap', label: 'Roadmap', href: '#roadmap' }
]
},
{
id: 'col-solutions',
heading: 'Solutions',
links: [
{ id: 'col-solutions-compliance', label: 'Compliance training', href: '#compliance' },
{ id: 'col-solutions-onboarding', label: 'Employee onboarding', href: '#onboarding' },
{ id: 'col-solutions-customer-ed', label: 'Customer education', href: '#customer-education' },
{ id: 'col-solutions-partner', label: 'Partner enablement', href: '#partner' },
{ id: 'col-solutions-sales', label: 'Sales enablement', href: '#sales' },
{ id: 'col-solutions-healthcare', label: 'Healthcare', href: '#healthcare' },
{ id: 'col-solutions-finance', label: 'Financial services', href: '#finance' },
{ id: 'col-solutions-government', label: 'Government', href: '#government' }
]
},
{
id: 'col-resources',
heading: 'Resources',
links: [
{ id: 'col-resources-help', label: 'Help Center', href: '#help' },
{ id: 'col-resources-blog', label: 'Blog', href: '#blog' }
{ id: 'col-resources-blog', label: 'Blog', href: '#blog' },
{ id: 'col-resources-help', label: 'Help center', href: '#help' },
{ id: 'col-resources-guides', label: 'Guides', href: '#guides' },
{ id: 'col-resources-webinars', label: 'Webinars', href: '#webinars' },
{ id: 'col-resources-podcast', label: 'Podcast', href: '#podcast' },
{ id: 'col-resources-templates', label: 'Templates', href: '#templates' },
{ id: 'col-resources-case-studies', label: 'Case studies', href: '#case-studies' },
{ id: 'col-resources-research', label: 'Research', href: '#research' },
{ id: 'col-resources-glossary', label: 'Glossary', href: '#glossary' }
]
},
{
id: 'col-developers',
heading: 'Developers',
links: [
{ id: 'col-developers-docs', label: 'Documentation', href: '#docs' },
{ id: 'col-developers-api', label: 'API reference', href: '#api' },
{ id: 'col-developers-sdks', label: 'SDKs', href: '#sdks' },
{ id: 'col-developers-webhooks', label: 'Webhooks', href: '#webhooks' },
{ id: 'col-developers-mcp', label: 'MCP server', href: '#mcp' },
{ id: 'col-developers-cli', label: 'CLI', href: '#cli' },
{ id: 'col-developers-status', label: 'System status', href: '#status' },
{ id: 'col-developers-changelog', label: 'API changelog', href: '#api-changelog' }
]
},
{
id: 'col-company',
heading: 'Company',
links: [
{ id: 'col-company-about', label: 'About', href: '#about' },
{ id: 'col-company-customers', label: 'Customers', href: '#customers' },
{ id: 'col-company-careers', label: 'Careers', href: '#careers' },
{ id: 'col-company-press', label: 'Press', href: '#press' },
{ id: 'col-company-partners', label: 'Partners', href: '#partners' },
{ id: 'col-company-contact', label: 'Contact', href: '#contact' },
{ id: 'col-company-events', label: 'Events', href: '#events' },
{ id: 'col-company-newsroom', label: 'Newsroom', href: '#newsroom' }
]
},
{
id: 'col-trust',
heading: 'Trust & Legal',
links: [
{ id: 'col-trust-security', label: 'Security', href: '#security' },
{ id: 'col-trust-soc2', label: 'SOC 2', href: '#soc2' },
{ id: 'col-trust-gdpr', label: 'GDPR', href: '#gdpr' },
{ id: 'col-trust-hipaa', label: 'HIPAA', href: '#hipaa' },
{ id: 'col-trust-iso', label: 'ISO 27001', href: '#iso' },
{ id: 'col-trust-dpa', label: 'Data processing addendum', href: '#dpa' },
{ id: 'col-trust-subprocessors', label: 'Subprocessors', href: '#subprocessors' },
{ id: 'col-trust-responsible-disclosure', label: 'Responsible disclosure', href: '#responsible-disclosure' }
]
}
],
@@ -181,6 +256,10 @@ export const mockProps: OrgLandingPageProps = {
links: [
{ id: 'bottom-terms', label: 'Terms of Service', href: '#terms' },
{ id: 'bottom-privacy', label: 'Privacy Policy', href: '#privacy' },
{ id: 'bottom-cookies', label: 'Cookie Settings', href: '#cookies' },
{ id: 'bottom-acceptable-use', label: 'Acceptable Use', href: '#acceptable-use' },
{ id: 'bottom-accessibility', label: 'Accessibility', href: '#accessibility' },
{ id: 'bottom-sitemap', label: 'Sitemap', href: '#sitemap' },
{ id: 'bottom-support', label: 'Support', href: '#support' }
]
}
@@ -6,6 +6,8 @@ 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 EditorialLandingPage } from './editorial.svelte';
export { default as VibrantLandingPage } from './vibrant.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';
@@ -22,6 +24,21 @@ 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 EditorialLandingNav } from './editorial/nav.svelte';
export { default as EditorialLandingHero } from './editorial/hero.svelte';
export { default as VibrantLandingNav } from './vibrant/nav.svelte';
export { default as VibrantLandingHero } from './vibrant/hero.svelte';
export { default as MinimalLandingCourseCard } from './minimal/course-card.svelte';
export { default as BoldLandingCourseCard } from './bold/course-card.svelte';
export { default as ClassicLandingCourseCard } from './classic/course-card.svelte';
export { default as SaasLandingCourseCard } from './saas/course-card.svelte';
export { default as TechLandingCourseCard } from './tech/course-card.svelte';
export { default as StudioLandingCourseCard } from './studio/course-card.svelte';
export { default as CorporateLandingCourseCard } from './corporate/course-card.svelte';
export { default as TerminalLandingCourseCard } from './terminal/course-card.svelte';
export { default as EditorialLandingCourseCard } from './editorial/course-card.svelte';
export { default as VibrantLandingCourseCard } from './vibrant/course-card.svelte';
export { default as LandingButton } from './landing-button.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte';
export { default as OrgLandingPageLinks } from './links.svelte';
@@ -35,4 +52,6 @@ export {
type LandingPageEditContext
} from './edit-context';
export * from './landing-page-link-icons';
export * from './theme-style';
export * from './types';
export { mockOrgLandingPageProps } from './fixtures';
@@ -0,0 +1,61 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'primary' | 'secondary' | 'tertiary';
type Size = 'sm' | 'md' | 'lg';
interface Props {
variant?: Variant;
size?: Size;
href?: string;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
onclick?: (event: MouseEvent) => void;
class?: string;
children: Snippet;
'aria-label'?: string;
}
let {
variant = 'primary',
size = 'md',
href,
disabled = false,
type = 'button',
onclick,
class: extraClass = '',
children,
'aria-label': ariaLabel
}: Props = $props();
const sizeClass = $derived(
size === 'sm'
? 'ui:px-3 ui:py-1.5 ui:text-sm'
: size === 'lg'
? 'ui:px-6 ui:py-3 ui:text-base'
: 'ui:px-4 ui:py-2 ui:text-sm'
);
const variantClass = $derived(
variant === 'primary'
? 'ui:bg-[var(--landing-button-primary-bg)] ui:text-[var(--landing-button-primary-fg)] ui:hover:bg-[var(--landing-button-primary-bg-hover)] ui:border-transparent'
: variant === 'secondary'
? 'ui:bg-[var(--landing-button-secondary-bg)] ui:text-[var(--landing-button-secondary-fg)] ui:hover:bg-[var(--landing-button-secondary-bg-hover)] ui:border-[var(--landing-button-secondary-border)]'
: 'ui:bg-transparent ui:text-[var(--landing-button-tertiary-fg)] ui:hover:bg-[var(--landing-button-tertiary-bg-hover)] ui:border-transparent'
);
const baseClass =
'ui:inline-flex ui:items-center ui:justify-center ui:gap-1.5 ui:rounded-md ui:font-medium ui:border ui:transition-colors ui:focus-visible:outline-none ui:focus-visible:ring-2 ui:focus-visible:ring-[var(--landing-accent)]/50 ui:disabled:opacity-50 ui:disabled:cursor-not-allowed ui:cursor-pointer ui:no-underline ui:whitespace-nowrap';
const finalClass = $derived(`${baseClass} ${sizeClass} ${variantClass} ${extraClass}`);
</script>
{#if href && !disabled}
<a {href} class={finalClass} aria-label={ariaLabel} {onclick}>
{@render children()}
</a>
{:else}
<button {type} class={finalClass} {disabled} {onclick} aria-label={ariaLabel}>
{@render children()}
</button>
{/if}
@@ -3,7 +3,6 @@
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 {
@@ -64,7 +63,7 @@
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}>
<a href="/" class="{tokens.brandName} ui:cursor-pointer">
{#if logoUrl}
<img src={logoUrl} alt="" class={tokens.logoImg} />
{/if}
@@ -79,7 +78,7 @@
<li class="ui:list-none">
<a
href={social.href}
class={tokens.socialIcon}
class="{tokens.socialIcon} ui:cursor-pointer"
target="_blank"
rel="noreferrer noopener"
aria-label={social.platform}
@@ -103,12 +102,12 @@
<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>
<a href={link.href} class="{tokens.columnLink} ui:cursor-pointer">{link.label}</a>
</li>
{/each}
</ul>
{#if hasCta(column)}
<a href={column.cta!.href} class={tokens.columnCta}>
<a href={column.cta!.href} class="{tokens.columnCta} ui:cursor-pointer">
{column.cta!.label}
<ArrowRightIcon class="ui:size-4 ui:shrink-0" aria-hidden="true" />
</a>
@@ -119,11 +118,9 @@
{/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>
{#snippet children()}
{@render gridInner()}
{/snippet}
{:else}
{@render gridInner()}
{/if}
@@ -139,7 +136,7 @@
{#if bottomLinks.length > 0}
<div class={tokens.bottomLinksWrap}>
{#each bottomLinks as link (link.id)}
<a href={link.href} class={tokens.bottomLink}>{link.label}</a>
<a href={link.href} class="{tokens.bottomLink} ui:cursor-pointer">{link.label}</a>
{/each}
</div>
{/if}
@@ -147,7 +144,7 @@
{/if}
{:else}
<div class={tokens.compactRow}>
<a href="/" class={tokens.brandName}>
<a href="/" class="{tokens.brandName} ui:cursor-pointer">
{#if logoUrl}
<img src={logoUrl} alt="" class={tokens.logoImg} />
{/if}
@@ -158,7 +155,7 @@
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>
<a href={link.href} class="{tokens.bottomLink} ui:cursor-pointer">{link.label}</a>
{/each}
</div>
{/if}
@@ -23,82 +23,83 @@ export type FooterRegionTokens = {
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',
root: 'ui:border-t ui:border-[var(--landing-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',
'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-[var(--landing-fg-muted)]',
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',
'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-[var(--landing-border)]/50 ui:text-sm ui:text-[var(--landing-fg-muted)]',
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',
'ui:font-semibold ui:text-xl ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-2',
columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)] 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',
'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
'ui:text-sm ui:font-semibold ui:text-[var(--landing-fg)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] 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',
'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-[var(--landing-bg)]/15 ui:text-xs ui:text-[var(--landing-bg)]/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]',
'ui:font-bold ui:text-xl ui:tracking-tight ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/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',
'ui:text-[11px] ui:font-mono ui:font-semibold ui:uppercase ui:tracking-widest ui:text-[var(--landing-bg)]/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',
'ui:block ui:text-sm ui:text-[var(--landing-bg)]/75 ui:hover:text-[var(--landing-bg)] 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',
'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:border ui:border-[var(--landing-bg)]/15 ui:bg-[var(--landing-bg)]/5 ui:text-[var(--landing-bg)]/75 ui:hover:text-[var(--landing-bg)] ui:hover:bg-[var(--landing-bg)]/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',
'ui:text-xs ui:font-mono ui:text-[var(--landing-bg)]/65 ui:hover:text-[var(--landing-bg)] 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',
root: 'ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-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',
'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-[var(--landing-fg-muted)]',
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',
'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-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]',
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',
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline:
'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-3 ui:max-w-[240px] ui:leading-relaxed',
columnHeading: 'ui:text-xs ui:font-medium ui:text-[var(--landing-fg-muted)] 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',
'ui:block ui:text-[13px] ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-fg-muted)] 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',
'ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:size-7 ui:rounded-md ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] 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',
'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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'
};
}
@@ -132,108 +133,165 @@ export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionToken
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',
root: 'ui:bg-[var(--landing-bg)] ui:border-t ui:border-[var(--landing-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',
'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-[var(--landing-fg-muted)]',
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',
'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-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]',
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',
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline:
'ui:text-sm ui:text-[var(--landing-fg-muted)] 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-[var(--landing-fg)] 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',
'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
'ui:text-sm ui:font-medium ui:text-[var(--landing-fg)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
root: 'ui:bg-[var(--landing-bg)] ui:border-t ui:border-[var(--landing-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',
'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-[var(--landing-fg-muted)]',
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',
'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-[var(--landing-border)] ui:text-xs ui:text-[var(--landing-fg-muted)]',
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',
'ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-3 ui:max-w-[260px]',
columnHeading: 'ui:text-sm ui:font-bold ui:text-[var(--landing-fg)] 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',
'ui:block ui:text-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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',
'ui:text-sm ui:font-semibold ui:text-[var(--landing-accent)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] 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',
'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] 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 === 'vibrant') {
return {
root: 'ui:bg-[var(--landing-card-soft)]/40 ui:text-[var(--landing-fg)] ui:py-20 ui:px-4 ui:sm:px-6 ui:md:py-24',
container: 'ui:max-w-[1320px] 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-[var(--landing-fg-muted)]',
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:border-t ui:border-[var(--landing-border)]/60 ui:text-sm ui:text-[var(--landing-fg-muted)]',
brandName:
'ui:font-bold ui:text-[42px] ui:md:text-[48px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
brandTagline:
'ui:text-base ui:text-[var(--landing-fg-muted)] ui:break-words ui:mt-4 ui:max-w-[280px] ui:leading-relaxed',
columnHeading:
'ui:text-xs ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-5',
columnLink:
'ui:block ui:text-lg ui:font-medium ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-accent)] ui:transition-colors ui:break-words ui:py-1.5 ui:md:py-1 ui:no-underline',
columnCta:
'ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] 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:size-9 ui:rounded-md ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:text-[var(--landing-accent)] 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-sm ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:break-words ui:inline-block ui:py-1 ui:md:py-0 ui:no-underline',
logoImg: 'ui:h-12 ui:md:h-14 ui:w-auto'
};
}
if (variant === 'editorial') {
return {
root: 'ui:bg-[#ebeae3] ui:text-[#1a1a1a] ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-16',
container: 'ui:max-w-[1240px] 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-[#76746c]',
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-[#d9d8d0] ui:text-xs ui:text-[#76746c]',
brandName:
'ui:font-semibold ui:text-base ui:tracking-tight ui:text-[#1a1a1a] ui:flex ui:items-center ui:gap-2 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[#76746c] ui:break-words ui:mt-3 ui:max-w-[260px] ui:leading-relaxed',
columnHeading: 'ui:text-[13px] ui:font-normal ui:text-[#76746c] ui:mb-3',
columnLink:
'ui:block ui:text-sm ui:text-[#2c2b29] ui:hover:text-[#1a1a1a] 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-[#c25237] 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-[#76746c] ui:hover:text-[#1a1a1a] 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-[#76746c] ui:hover:text-[#1a1a1a] 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 === 'bold') {
return {
root: 'ui:bg-foreground ui:text-background/60 ui:py-14 ui:px-4 ui:sm:px-6 ui:md:py-20',
root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]/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',
'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-[var(--landing-bg)]/15 ui:text-xs ui:text-[var(--landing-bg)]/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',
'ui:font-black ui:text-2xl ui:tracking-tighter ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/60 ui:break-words ui:mt-2',
columnHeading: 'ui:text-xs ui:font-bold ui:uppercase ui:tracking-widest ui:text-[var(--landing-bg)] 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',
'ui:block ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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',
'ui:text-sm ui:font-bold ui:text-[var(--landing-bg)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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',
'ui:text-xs ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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',
root: 'ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)] 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',
'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-[var(--landing-bg)]/70',
bottomHairline: 'ui:h-px ui:w-16 ui:md:w-24 ui:bg-[var(--landing-bg)]/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',
'ui:text-xl ui:font-bold ui:tracking-wide ui:text-[var(--landing-bg)] ui:flex ui:items-center ui:gap-3 ui:no-underline ui:break-words',
brandTagline: 'ui:text-sm ui:text-[var(--landing-bg)]/70 ui:break-words ui:mt-2',
columnHeading: 'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] 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',
'ui:block ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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',
'ui:text-sm ui:font-semibold ui:text-[var(--landing-bg)] 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',
'ui:inline-flex ui:items-center ui:justify-center ui:p-1.5 ui:rounded-md ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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',
'ui:text-sm ui:font-medium ui:text-[var(--landing-bg)]/70 ui:hover:text-[var(--landing-bg)] 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'
};
}
@@ -72,3 +72,15 @@ export function calcCourseDiscount(percent = 0, cost: number, showDiscount: bool
const discountedPrice = cost - discountAmount;
return Math.round(discountedPrice);
}
export function defaultLessonsLabel(count: number): string {
return count === 1 ? '1 lesson' : `${count.toLocaleString()} lessons`;
}
export function defaultExercisesLabel(count: number): string {
return count === 1 ? '1 exercise' : `${count.toLocaleString()} exercises`;
}
export function defaultEnrolledLabel(count: number): string {
return `${count.toLocaleString()} enrolled`;
}
@@ -1,14 +1,23 @@
<script lang="ts">
import type { LandingPageLinks, OrgLandingPageTheme } from './types';
import type { LandingPageLinks, OrgLandingPageLabels, 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();
let {
links,
variant = 'minimal',
labels
}: {
links?: LandingPageLinks;
variant?: OrgLandingPageTheme;
labels?: OrgLandingPageLabels;
} = $props();
const resolvedResourcesEyebrow = $derived(labels?.resourcesEyebrow ?? 'Resources');
function linkDisplayHint(href: string) {
const trimmed = href.trim();
@@ -29,144 +38,130 @@
{#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>
<h2 class="ui:text-2xl ui:font-semibold {links.description?.trim() ? 'ui:mb-3' : 'ui:mb-10'}">
{links.heading}
</h2>
{#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>
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mb-10">
{links.description}
</p>
{/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)`}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<Card.Root
class="ui:rounded-none ui:border-[var(--landing-border)]/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 ui:transition-colors ui:hover:border-[var(--landing-border)]"
>
<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>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1 ui:h-full">
<IconComponent class="ui:size-6 ui:text-[var(--landing-fg)] 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-[var(--landing-fg-muted)] 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-[var(--landing-fg-muted)] 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>
{/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">
<section
class="ui:relative ui:py-24 ui:px-6 ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] 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>
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:mb-3">{links.heading}</h2>
{#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>
<p class="ui:text-lg ui:text-[var(--landing-accent-fg)]/80 ui:max-w-2xl ui:mb-14 ui:font-mono">
{links.description}
</p>
{: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"
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:border ui:border-[var(--landing-accent-fg)]/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)`}
<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-[var(--landing-accent-fg)]/15 ui:transition-colors ui:hover:bg-[var(--landing-accent-fg)]/5 ui:no-underline ui:text-[var(--landing-accent-fg)] ui:cursor-pointer"
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-[var(--landing-accent-fg)]/15 ui:text-[var(--landing-accent-fg)] ui:font-mono ui:font-bold ui:mb-5"
>
<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>
<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-[var(--landing-accent-fg)]/70 ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
{/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">
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]">
<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>
<div>
<p class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
{resolvedResourcesEyebrow}
</p>
<h2 class="ui:text-3xl ui:lg:text-4xl ui:font-semibold ui:tracking-tight ui:m-0">{links.heading}</h2>
</div>
{#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>
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:m-0">
{links.description}
</p>
{/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)`}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:p-6 ui:bg-[var(--landing-card)] ui:border ui:border-[var(--landing-border)] ui:rounded-xl ui:transition-colors ui:hover:border-[var(--landing-fg)]/30 ui:hover:bg-[var(--landing-card-soft)]/30 ui:no-underline ui:cursor-pointer"
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-[var(--landing-card-soft)] ui:border ui:border-[var(--landing-border)] ui:text-[var(--landing-accent)] ui:mb-3"
>
<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>
<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-[var(--landing-fg)]">
{card.title}
</h3>
<p class="ui:text-[13px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
{/each}
</div>
</div>
@@ -185,166 +180,259 @@
<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%),
radial-gradient(ellipse 60% 100% at 50% 0%, color-mix(in oklab, var(--landing-accent) 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>
<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(--landing-accent);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
></span>
{resolvedResourcesEyebrow}
</p>
<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>
<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)`}
<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 ui:cursor-pointer"
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(--landing-accent);"
>
<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>
<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>
{/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">
<section
class="ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/30 ui:border-t ui:border-b ui:border-[var(--landing-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">
<p class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-fg)] ui:mb-3">
{resolvedResourcesEyebrow}
</p>
<h2 class="ui:text-3xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-10">{links.heading}</h2>
<div
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-[var(--landing-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)`}
<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-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:hover:bg-[var(--landing-card-soft)]/40 ui:no-underline ui:h-full ui:cursor-pointer"
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-[var(--landing-fg)] ui:text-[var(--landing-bg)] ui:flex-shrink-0"
>
<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>
<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-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</div>
</a>
{/each}
</div>
</div>
</section>
{:else if variant === 'saas'}
<section class="ui:py-20 ui:px-6 ui:bg-muted/40">
<section class="ui:py-20 ui:px-6 ui:bg-[var(--landing-card-soft)]/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>
<p
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
>
{resolvedResourcesEyebrow}
</p>
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight ui:m-0">
{links.heading}
</h2>
{#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>
<p class="ui:text-base ui:text-[var(--landing-fg-muted)] ui:mt-3 ui:leading-relaxed">
{links.description}
</p>
{/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"
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-[1px] ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-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)`}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:bg-[var(--landing-bg)] ui:p-7 ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:transition-colors ui:hover:bg-[var(--landing-card-soft)]/40 ui:no-underline ui:h-full ui:cursor-pointer"
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-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-1"
>
<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>
<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-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0">
{card.description}
</p>
</a>
{/each}
</div>
</div>
</section>
{:else if variant === 'vibrant'}
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:bg-[var(--landing-bg)]">
<div class="ui:max-w-[1320px] ui:mx-auto">
<h2
class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:text-center ui:m-0 ui:mb-3 ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.025em; line-height: 1.1;"
>
{links.heading}
</h2>
{#if links.description?.trim()}
<p class="ui:text-lg ui:text-[var(--landing-fg-muted)] ui:text-center ui:max-w-xl ui:mx-auto ui:mb-14">
{links.description}
</p>
{: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:gap-5">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:flex ui:flex-col ui:h-full ui:p-8 ui:rounded-[20px] ui:bg-[var(--landing-card-soft)]/60 ui:no-underline ui:transition-colors ui:hover:bg-[var(--landing-card-soft)] ui:cursor-pointer"
aria-label={`${card.title} — ${linkDisplayHint(card.href)} (opens in new tab)`}
>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-11 ui:rounded-xl ui:bg-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-5"
>
<IconComponent class="ui:size-5" aria-hidden="true" />
</div>
<h3 class="ui:text-lg ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-2 ui:text-[var(--landing-fg)]">
{card.title}
</h3>
<p
class="ui:text-[15px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-6"
>
{card.description}
</p>
<span
class="ui:mt-auto ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)]"
aria-hidden="true"
>
{labels?.learnMoreLabel ?? 'Learn more'}
<ArrowRightIcon class="ui:size-3.5 ui:transition-transform ui:group-hover:translate-x-0.5" />
</span>
</a>
{/each}
</div>
</div>
</section>
{:else if variant === 'editorial'}
{@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'}
<section class="ui:py-24 ui:md:py-28 ui:px-6 ui:bg-[#f4f3ed]">
<div class="ui:max-w-[1240px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-10">
<div>
<p class="ui:text-[13px] ui:text-[#76746c] ui:mb-2">{resolvedResourcesEyebrow}</p>
<h2
class="ui:text-3xl ui:md:text-[38px] ui:font-medium ui:tracking-tight ui:m-0 ui:text-[#1a1a1a] ui:leading-[1.1]"
>
{links.heading}
</h2>
{#if links.description?.trim()}
<p class="ui:text-base ui:text-[#76746c] ui:mt-3 ui:max-w-xl ui:leading-relaxed">
{links.description}
</p>
{/if}
</div>
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 {lgColsClass} ui:gap-4">
{#each links.cards as card, index (index)}
{@const IconComponent = landingPageLinkIconMap[card.icon]}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:flex ui:flex-col ui:h-full ui:p-7 ui:rounded-lg ui:bg-[#ecebe5] ui:no-underline ui:transition-colors ui:hover:bg-[#e2e1d9] ui:cursor-pointer"
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 ui:bg-[#fafaf5] ui:border ui:border-[#d9d8d0] ui:text-[#c25237] ui:mb-5"
>
<IconComponent class="ui:size-4" aria-hidden="true" />
</div>
<h3 class="ui:text-base ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-1.5 ui:text-[#1a1a1a]">
{card.title}
</h3>
<p class="ui:text-[13.5px] ui:text-[#76746c] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-5">
{card.description}
</p>
<span
class="ui:mt-auto ui:inline-flex ui:items-center ui:gap-1 ui:text-[13.5px] ui:font-medium ui:text-[#c25237]"
aria-hidden="true"
>
{labels?.learnMoreLabel ?? 'Visit'}
<ArrowRightIcon class="ui:size-3.5 ui:transition-transform ui:group-hover:translate-x-0.5" />
</span>
</a>
{/each}
</div>
</div>
</section>
{:else if variant === 'bold'}
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-background ui:overflow-hidden">
<section class="ui:relative ui:py-20 ui:px-6 ui:bg-[var(--landing-bg)] 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>
<h2 class="ui:text-4xl ui:font-black ui:tracking-tight ui:mb-3">{links.heading}</h2>
{#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>
<p class="ui:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mb-12">{links.description}</p>
{:else}
<div class="ui:mb-12" aria-hidden="true"></div>
{/if}
@@ -352,46 +440,44 @@
{#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)`}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
aria-label={`${card.title}${visitLabel ? ` — ${visitLabel}` : ''} (opens in new tab)`}
>
<Card.Root
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:bg-[var(--landing-bg)] ui:border-[var(--landing-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"
>
<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(--landing-accent)"
colorTo="color-mix(in oklab, var(--landing-accent) 65%, white)"
/>
<div
class="ui:inline-flex ui:items-center ui:justify-center ui:size-12 ui:rounded-2xl ui:bg-[var(--landing-accent)]/10 ui:text-[var(--landing-accent)] ui:mb-6"
>
<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-[var(--landing-fg-muted)] 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-[var(--landing-fg)] ui:bg-[var(--landing-card-soft)] ui:px-3 ui:py-1.5 ui:rounded-lg ui:transition-colors ui:group-hover:bg-[var(--landing-accent)] ui:group-hover:text-[var(--landing-accent-fg)]"
aria-hidden="true"
>
<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>
{#if visitLabel}
{visitLabel}
{/if}
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</Card.Root>
</a>
{/each}
</div>
</div>
@@ -399,18 +485,12 @@
{: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>
<h2 class="ui:text-3xl ui:font-bold ui:text-[var(--landing-fg)]">{links.heading}</h2>
<div class="ui:mt-6 ui:h-px ui:w-24 ui:bg-[var(--landing-border)] ui:mx-auto"></div>
{#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>
<p class="ui:mt-6 ui:text-center ui:text-[var(--landing-fg-muted)] ui:max-w-2xl ui:mx-auto">
{links.description}
</p>
{/if}
</div>
<div
@@ -419,36 +499,36 @@
{#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)`}
<a
href={card.href}
target="_blank"
rel="noreferrer"
class="ui:group ui:block ui:h-full ui:no-underline ui:cursor-pointer"
aria-label={`${card.title}${learnMoreLabel ? ` — ${learnMoreLabel}` : ''} (opens in new tab)`}
>
<Card.Root
class="ui:w-full ui:rounded-xl ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:p-6 ui:h-full ui:flex ui:flex-col ui:hover:shadow-md ui:transition-shadow"
>
<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-[var(--landing-card-soft)] ui:mb-4"
>
<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>
<IconComponent class="ui:size-5 ui:text-[var(--landing-fg)]" 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-[var(--landing-fg-muted)] ui:line-clamp-3 ui:mb-4">
{card.description}
</Card.Description>
<div
class="ui:text-sm ui:font-medium ui:text-[var(--landing-fg)] ui:border-t ui:border-[var(--landing-border)]/60 ui:pt-3 ui:mt-auto ui:flex ui:items-center ui:gap-1.5 ui:group-hover:text-[var(--landing-accent)] ui:transition-colors"
aria-hidden="true"
>
{#if learnMoreLabel}
<span>{learnMoreLabel}</span>
{/if}
<ArrowRightIcon class="ui:size-4" />
</div>
</Card.Root>
</a>
{/each}
</div>
</section>
@@ -6,12 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import MinimalNav from './minimal/nav.svelte';
import MinimalHero from './minimal/hero.svelte';
import MinimalCourseCard from './minimal/course-card.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import * as Card from '../../base/card';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from './landing-page-utils';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -25,16 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: 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">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('minimal')}
>
<main>
<MinimalHero {hero}>
{#snippet navigation()}
@@ -44,68 +40,10 @@
<section class="ui:py-20 ui:px-4">
<div class="ui:max-w-[1200px] ui:mx-auto">
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">Our Courses</h2>
<h2 class="ui:text-2xl ui:font-semibold ui:mb-8">{labels?.catalogHeading ?? '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
href={disableCourseLinks
? undefined
: course.link || (course.slug ? `/course/${course.slug}` : undefined)}
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<Card.Root
class="ui:rounded-none ui:border-border/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
? ''
: 'ui:hover:border-border ui:transition-colors'}"
>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
{course.description}
</Card.Description>
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
{#if course.duration}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-muted-foreground">
<ClockIcon class="ui:size-4" />
<span>{course.duration}</span>
</div>
{/if}
{#if 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" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
<div class="ui:ml-auto ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</div>
</div>
</Card.Content>
</Card.Root>
</a>
</BlurFade>
<MinimalCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
@@ -117,7 +55,7 @@
size="lg"
disabled={disableCourseLinks}
>
View more courses
{labels?.browseCoursesLabel ?? 'View more courses'}
</Button>
</div>
{/if}
@@ -125,11 +63,11 @@
</section>
</main>
<OrgLandingPageLinks {links} variant="minimal" />
<OrgLandingPageLinks {links} {labels} variant="minimal" />
<OrgLandingPageEmbed {embed} variant="minimal" />
<OrgLandingPageEmbed {embed} {labels} variant="minimal" />
<OrgLandingPageCallout {callout} variant="minimal" />
<OrgLandingPageCallout {callout} {labels} variant="minimal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
</div>
@@ -0,0 +1,82 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import * as Card from '../../../base/card';
import ClockIcon from '@lucide/svelte/icons/clock';
import TagIcon from '@lucide/svelte/icons/tag';
import { getCourseTypeLandingMeta, getPrimaryCourseTag } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const primaryTag = $derived(getPrimaryCourseTag(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<a
{href}
class="ui:block ui:h-full ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer ui:transition-colors'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<Card.Root
class="ui:rounded-none ui:border-[var(--landing-border)]/60 ui:shadow-none ui:h-full ui:p-0 ui:gap-0 {disableCourseLinks
? ''
: 'ui:hover:border-[var(--landing-border)] ui:transition-colors'}"
>
<Card.Content class="ui:p-8 ui:flex ui:flex-col ui:flex-1">
<Card.Title class="ui:text-xl ui:font-normal ui:mb-4">{course.title}</Card.Title>
<Card.Description class="ui:mb-8 ui:line-clamp-3 ui:text-base ui:leading-relaxed">
{course.description}
</Card.Description>
<div class="ui:flex ui:items-center ui:gap-6 ui:text-sm ui:mt-auto">
{#if course.duration}
<div class="ui:flex ui:items-center ui:gap-1.5 ui:text-[var(--landing-fg-muted)]">
<ClockIcon class="ui:size-4" />
<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-[var(--landing-fg-muted)]">
<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-[var(--landing-fg-muted)]">
<TagIcon class="ui:size-4" />
{#if primaryTag.color}
<span
class="ui:inline-block ui:h-2 ui:w-2 ui:rounded-full"
style={`background-color: ${primaryTag.color}`}
aria-hidden="true"
></span>
{/if}
<span>{primaryTag.name}</span>
</div>
{/if}
<div class="ui:ml-auto ui:font-semibold">
{course.price || formatCurrency(course.cost, course.currency)}
</div>
</div>
</Card.Content>
</Card.Root>
</a>
@@ -3,7 +3,6 @@
import type { Snippet } from 'svelte';
import SecondaryActionButton from '../secondary-action-button.svelte';
import { Button } from '../../../base/button';
import { BlurFade } from '../../animation/blurfade';
import { DotPattern } from '../../animation/dot-pattern';
import { ShinnyText } from '../../animation/shinny-text';
import EditableLandingSection from '../editable-section.svelte';
@@ -18,14 +17,14 @@
const heroGradientBackground = `
radial-gradient(
circle at top center,
color-mix(in oklab, var(--primary) 24%, transparent) 0%,
color-mix(in oklab, var(--landing-accent) 24%, transparent) 0%,
transparent 58%
),
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 12%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 6%, var(--background)) 48%,
var(--background) 100%
color-mix(in oklab, var(--landing-accent) 12%, var(--landing-bg)) 0%,
color-mix(in oklab, var(--landing-accent) 6%, var(--landing-bg)) 48%,
var(--landing-bg) 100%
)
`;
</script>
@@ -41,36 +40,30 @@
</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"
<h1 class="ui:text-5xl ui:md:text-6xl ui:font-semibold ui:tracking-tight ui:mb-6">{hero.heading}</h1>
<ShinnyText
shimmerWidth={120}
class="ui:text-xl ui:mb-10 ui:max-w-2xl ui:mx-auto ui:font-light ui:text-[var(--landing-fg)]/72"
>
{hero.subheading}
</ShinnyText>
<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.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>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="minimal"
/>
{/if}
</div>
</div>
</section>
</EditableLandingSection>
@@ -18,18 +18,18 @@
const navigationGradientBackground = `
linear-gradient(
135deg,
color-mix(in oklab, var(--primary) 16%, var(--background)) 0%,
color-mix(in oklab, var(--primary) 8%, var(--background)) 100%
color-mix(in oklab, var(--landing-accent) 16%, var(--landing-bg)) 0%,
color-mix(in oklab, var(--landing-accent) 8%, var(--landing-bg)) 100%
)
`;
</script>
<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"
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-[var(--landing-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">
<a href="/" class="ui:font-semibold ui:text-xl ui:flex ui:items-center ui:gap-2 ui:no-underline ui:cursor-pointer">
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-8 ui:w-auto" />
{/if}
@@ -38,7 +38,9 @@
<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"
<a
href={item.href}
class="ui:text-sm ui:text-[var(--landing-fg)]/70 ui:hover:text-[var(--landing-fg)] ui:transition-colors ui:cursor-pointer"
>{item.label}</a
>
{/each}
@@ -6,12 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import SaasNav from './saas/nav.svelte';
import SaasHero from './saas/hero.svelte';
import SaasCourseCard from './saas/course-card.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';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -25,16 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: 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="saas-root ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('saas')}
>
<div class="frame">
<SaasHero {hero}>
{#snippet navigation()}
@@ -49,8 +45,14 @@
<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>
<p
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
>
{labels?.catalogEyebrow ?? 'Catalog'}
</p>
<h2 class="ui:text-3xl ui:md:text-4xl ui:font-bold ui:tracking-tight">
{labels?.catalogHeading ?? 'Featured programs.'}
</h2>
</div>
{#if hasMoreCourses}
<Button
@@ -59,90 +61,14 @@
class="ui:rounded-full ui:self-start ui:md:self-auto"
disabled={disableCourseLinks}
>
Browse catalog
{labels?.browseCoursesLabel ?? '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>
<SaasCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
</div>
@@ -151,17 +77,17 @@
{#if links && links.cards.length > 0}
<div class="plus-row"></div>
<OrgLandingPageLinks {links} variant="saas" />
<OrgLandingPageLinks {links} {labels} variant="saas" />
{/if}
{#if embed}
<div class="plus-row"></div>
<OrgLandingPageEmbed {embed} variant="saas" />
<OrgLandingPageEmbed {embed} {labels} variant="saas" />
{/if}
{#if callout}
<div class="plus-row"></div>
<OrgLandingPageCallout {callout} variant="saas" />
<OrgLandingPageCallout {callout} {labels} variant="saas" />
{/if}
</div>
@@ -177,7 +103,7 @@
width: 100%;
background-image: radial-gradient(
circle at center,
color-mix(in oklab, var(--foreground) 5%, transparent) 1px,
color-mix(in oklab, var(--landing-fg) 5%, transparent) 1px,
transparent 1.5px
);
background-size: 24px 24px;
@@ -189,7 +115,7 @@
top: 0;
bottom: 0;
width: 1px;
background: var(--border);
background: var(--landing-border);
pointer-events: none;
z-index: 0;
}
@@ -208,7 +134,7 @@
.saas-root :global(.plus-row) {
position: relative;
height: 0;
border-top: 1px dashed var(--border);
border-top: 1px dashed var(--landing-border);
margin: 0;
}
.saas-root :global(.plus-row::before),
@@ -219,9 +145,9 @@
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 13px;
font-weight: 400;
color: var(--muted-foreground);
color: var(--landing-fg-muted);
line-height: 1;
background: var(--background);
background: var(--landing-bg);
padding: 1px 3px;
z-index: 2;
}
@@ -237,8 +163,8 @@
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
background: var(--landing-border);
border: 1px solid var(--landing-border);
}
.saas-root :global(.course-grid > .course-card-wrap) {
grid-column: span 2;
@@ -256,7 +182,7 @@
max-width: 520px;
}
.saas-root :global(.course-grid:has(> .course-card-wrap:only-child) .course-card) {
border: 1px solid var(--border);
border: 1px solid var(--landing-border);
border-radius: 0.5rem;
overflow: hidden;
}
@@ -0,0 +1,98 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
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';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const primaryTag = $derived(getPrimaryCourseTag(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<a
{href}
class="course-card ui:flex ui:flex-col ui:h-full ui:no-underline ui:bg-[var(--landing-bg)] ui:transition-colors {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/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-[var(--landing-bg)]/95 ui:text-[var(--landing-fg)] 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-[var(--landing-fg-muted)] 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-[var(--landing-fg-muted)] 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-[var(--landing-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-[var(--landing-accent)]"
aria-hidden="true"
>
{labels?.enrollLabel ?? 'Enroll'}
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</div>
</a>
@@ -3,7 +3,6 @@
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 {
@@ -21,48 +20,40 @@
</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>
<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 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>
<p
class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mx-auto ui:mb-8 ui:leading-relaxed"
>
{hero.subheading}
</p>
<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>
<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>
{#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>
<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-[var(--landing-border)] ui:shadow-[0_20px_60px_-30px_rgba(14,14,16,0.25)]"
/>
</div>
{/if}
</div>
</section>
@@ -18,11 +18,11 @@
<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)]"
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-[var(--landing-border)] ui:bg-[var(--landing-bg)]/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"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[15px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
@@ -35,7 +35,7 @@
{#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"
class="ui:px-3.5 ui:py-1.5 ui:text-sm ui:font-medium ui:text-[var(--landing-fg)]/80 ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]/60 ui:rounded-full ui:transition-colors ui:no-underline ui:cursor-pointer"
>
{item.label}
</a>
@@ -23,15 +23,17 @@
},
bold: {
className:
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-primary ui:transition-all',
'ui:rounded-xl ui:px-8 ui:py-6 ui:text-base ui:font-bold ui:border-2 ui:hover:border-[var(--landing-accent)] ui:transition-all',
variant: 'outline'
},
classic: {
className: 'ui:px-8 ui:font-semibold ui:bg-background ui:text-foreground ui:border-border ui:transition-colors',
className:
'ui:px-8 ui:font-semibold ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:border-[var(--landing-border)] ui:transition-colors',
variant: 'outline'
},
saas: {
className: 'ui:rounded-full ui:px-6 ui:border-border ui:text-foreground ui:hover:bg-accent',
className:
'ui:rounded-full ui:px-6 ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-accent',
variant: 'outline'
},
tech: {
@@ -41,18 +43,28 @@
},
studio: {
className:
'ui:rounded-md ui:px-5 ui:border-border ui:bg-background ui:text-foreground ui:hover:bg-muted ui:transition-colors',
'ui:rounded-md ui:px-5 ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] 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',
'ui:rounded-none ui:px-6 ui:font-medium ui:border-[var(--landing-fg)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-fg)] ui:hover:text-[var(--landing-bg)] 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'
},
editorial: {
className:
'ui:rounded-full ui:px-5 ui:font-medium ui:bg-transparent ui:border-[#d9d8d0] ui:text-[#1a1a1a] ui:hover:bg-[#ebeae3] ui:transition-colors',
variant: 'outline'
},
vibrant: {
className:
'ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:transition-colors',
variant: 'outline'
}
};
</script>
@@ -6,10 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import StudioNav from './studio/nav.svelte';
import StudioHero from './studio/hero.svelte';
import StudioCourseCard from './studio/course-card.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';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -23,16 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: 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">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('studio')}
>
<main>
<StudioHero {hero} {courses}>
{#snippet navigation()}
@@ -41,16 +39,16 @@
</StudioHero>
{#if courses.length > 0}
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-border">
<section class="ui:py-24 ui:px-6 ui:border-t ui:border-[var(--landing-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 class="ui:text-sm ui:text-[var(--landing-fg-muted)] ui:mb-1.5 ui:inline-flex ui:items-center ui:gap-2">
<span class="ui:size-1.5 ui:rounded-full ui:bg-[var(--landing-accent)]"></span>
{labels?.catalogEyebrow ?? '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.
{labels?.catalogHeading ?? 'Programs your team can actually finish.'}
</h2>
</div>
{#if hasMoreCourses}
@@ -61,7 +59,7 @@
class="ui:rounded-md"
disabled={disableCourseLinks}
>
Browse all →
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
</div>
{/if}
@@ -69,59 +67,7 @@
<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>
<StudioCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
</div>
@@ -129,11 +75,11 @@
{/if}
</main>
<OrgLandingPageLinks {links} variant="studio" />
<OrgLandingPageLinks {links} {labels} variant="studio" />
<OrgLandingPageEmbed {embed} variant="studio" />
<OrgLandingPageEmbed {embed} {labels} variant="studio" />
<OrgLandingPageCallout {callout} variant="studio" />
<OrgLandingPageCallout {callout} {labels} variant="studio" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<a
{href}
class="ui:group ui:flex ui:flex-col ui:h-full ui:bg-[var(--landing-card)] ui:border ui:border-[var(--landing-border)] ui:rounded-xl ui:overflow-hidden ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer ui:hover:border-[var(--landing-fg)]/30'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
<div
class="ui:relative ui:h-[200px] ui:bg-[var(--landing-card-soft)]/50 ui:border-b ui:border-[var(--landing-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(--landing-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-[var(--landing-bg)]/95 ui:border ui:border-[var(--landing-border)] ui:rounded-md ui:text-[11px] ui:font-medium ui:text-[var(--landing-fg)]"
>
{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-[var(--landing-fg-muted)] 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-[var(--landing-border)] ui:text-[var(--landing-fg-muted)] ui:group-hover:bg-[var(--landing-fg)] ui:group-hover:text-[var(--landing-bg)] ui:group-hover:border-[var(--landing-fg)] ui:transition-colors"
aria-hidden="true"
aria-label={labels?.enrollLabel ?? 'Enroll'}
>
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
</a>
@@ -3,7 +3,6 @@
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 {
@@ -12,7 +11,7 @@
navigation: Snippet;
}
let { hero, courses, navigation }: Props = $props();
let { hero, courses = [], navigation }: Props = $props();
const programsCount = $derived(courses.length);
@@ -32,83 +31,74 @@
<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%);"
style="background: radial-gradient(circle, color-mix(in oklab, var(--landing-accent) 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%);"
style="background: radial-gradient(circle, color-mix(in oklab, var(--landing-accent) 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="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-[var(--landing-border)] ui:bg-[var(--landing-card)]/70 ui:backdrop-blur-sm ui:text-[12px] ui:font-medium ui:text-[var(--landing-fg)]"
>
<span class="studio-status-dot ui:size-1.5 ui:rounded-full" aria-hidden="true"></span>
{statusLabel}
</span>
<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-[var(--landing-fg)] ui:text-balance"
>
{hero.heading}
</h1>
<p class="ui:text-base ui:md:text-lg ui:text-[var(--landing-fg-muted)] ui:max-w-xl ui:mb-8 ui:leading-relaxed">
{hero.subheading}
</p>
<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"
>
<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>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="studio" />
{/if}
</div>
{#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>
<div
class="studio-polaroid ui:mt-16 ui:relative ui:rounded-xl ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-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-[var(--landing-border)]"
/>
</div>
{/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>
<div
class="studio-marquee-mask ui:mt-16 ui:py-4 ui:border-y ui:border-[var(--landing-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-[var(--landing-accent)]/70"></span>
<span class="ui:text-sm ui:font-medium ui:text-[var(--landing-fg-muted)]">
{course.title}
</span>
{/each}
</div>
</span>
{/each}
</div>
</BlurFade>
</div>
{/if}
</div>
</section>
@@ -116,20 +106,20 @@
<style>
.studio-status-dot {
background-color: var(--primary);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 60%, transparent);
background-color: var(--landing-accent);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 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);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 50%, transparent);
}
70% {
box-shadow: 0 0 0 8px color-mix(in oklab, var(--primary) 0%, transparent);
box-shadow: 0 0 0 8px color-mix(in oklab, var(--landing-accent) 0%, transparent);
}
100% {
box-shadow: 0 0 0 0 color-mix(in oklab, var(--primary) 0%, transparent);
box-shadow: 0 0 0 0 color-mix(in oklab, var(--landing-accent) 0%, transparent);
}
}
@@ -17,12 +17,14 @@
</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">
<header
class="ui:sticky ui:top-0 ui:z-50 ui:bg-[var(--landing-bg)]/75 ui:backdrop-blur-md ui:border-b ui:border-[var(--landing-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"
class="ui:flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[14px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-5 ui:w-auto" />
@@ -35,7 +37,7 @@
{#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"
class="ui:px-3 ui:py-1.5 ui:text-[13px] ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)] ui:rounded-md ui:transition-colors ui:no-underline ui:cursor-pointer"
>
{item.label}
</a>
@@ -6,11 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TechNav from './tech/nav.svelte';
import TechHero from './tech/hero.svelte';
import TechCourseCard from './tech/course-card.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';
import { themeStyle } from './theme-style';
let {
orgName,
@@ -24,16 +22,15 @@
embed,
callout,
links,
footer
footer,
labels
}: 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">
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('tech')}
>
<main>
<TechHero {hero}>
{#snippet navigation()}
@@ -46,8 +43,14 @@
<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>
<p
class="ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
>
{labels?.catalogEyebrow ?? '// catalog'}
</p>
<h2 class="ui:text-4xl ui:lg:text-5xl ui:font-extrabold ui:tracking-tight ui:m-0">
{labels?.catalogHeading ?? 'Latest courses'}
</h2>
</div>
{#if hasMoreCourses}
<Button
@@ -56,66 +59,16 @@
class="ui:rounded-none ui:font-mono ui:text-sm"
disabled={disableCourseLinks}
>
view all →
{labels?.browseCoursesLabel ?? '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"
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-[var(--landing-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>
<TechCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
</div>
@@ -123,11 +76,11 @@
{/if}
</main>
<OrgLandingPageLinks {links} variant="tech" />
<OrgLandingPageLinks {links} {labels} variant="tech" />
<OrgLandingPageEmbed {embed} variant="tech" />
<OrgLandingPageEmbed {embed} {labels} variant="tech" />
<OrgLandingPageCallout {callout} variant="tech" />
<OrgLandingPageCallout {callout} {labels} variant="tech" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import ArrowRightIcon from '@lucide/svelte/icons/arrow-right';
import ClockIcon from '@lucide/svelte/icons/clock';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCurrency(cost?: number, currency = 'USD') {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(cost);
}
</script>
<a
{href}
class="ui:flex ui:flex-col ui:h-full ui:p-7 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)] ui:transition-colors ui:no-underline {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer ui:hover:bg-[var(--landing-card-soft)]/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-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] 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-[var(--landing-fg-muted)] 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-[var(--landing-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-[var(--landing-accent)] ui:font-bold"
aria-hidden="true"
>
{labels?.enrollLabel ?? 'enroll'}
<ArrowRightIcon class="ui:size-3.5" />
</span>
</div>
{#if course.duration}
<p
class="ui:font-mono ui:text-[11px] ui:text-[var(--landing-fg-muted)] ui:mt-3 ui:inline-flex ui:items-center ui:gap-1.5"
>
<ClockIcon class="ui:size-3" />
{course.duration}
</p>
{/if}
</a>
@@ -3,7 +3,6 @@
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 {
@@ -15,7 +14,9 @@
</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">
<section
class="ui:relative ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] 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]"
@@ -31,56 +32,46 @@
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"
<p
class="ui:inline-block ui:font-mono ui:text-xs ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent-fg)]/80 ui:bg-[var(--landing-accent-fg)]/10 ui:px-3 ui:py-1.5 ui:mb-6"
>
// engineering academy
</p>
<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>
<p
class="ui:text-lg ui:lg:text-xl ui:text-[var(--landing-accent-fg)]/80 ui:max-w-xl ui:mb-8 ui:leading-relaxed"
>
{hero.subheading}
</p>
<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-[var(--landing-accent-fg)] ui:text-[var(--landing-accent)] ui:hover:bg-[var(--landing-accent-fg)]/90"
>
// 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>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="tech" />
{/if}
</div>
</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>
<div class="ui:relative">
<div
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent-fg)]/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-[var(--landing-accent-fg)]/20"
/>
</div>
{/if}
</div>
</section>
@@ -17,10 +17,10 @@
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-primary-foreground">
<header class="ui:flex ui:items-center ui:justify-between ui:gap-6 ui:py-4 ui:text-[var(--landing-accent-fg)]">
<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"
class="ui:flex ui:items-center ui:gap-2 ui:font-bold ui:text-base ui:tracking-tight ui:text-[var(--landing-accent-fg)] ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
@@ -33,7 +33,7 @@
{#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"
class="ui:px-3 ui:py-1.5 ui:rounded-full ui:border ui:border-transparent ui:text-[var(--landing-accent-fg)]/85 ui:hover:text-[var(--landing-accent-fg)] ui:hover:border-[var(--landing-accent-fg)]/30 ui:hover:bg-[var(--landing-accent-fg)]/10 ui:transition-colors ui:no-underline ui:cursor-pointer"
>
( {item.label.toLowerCase()} )
</a>
@@ -46,7 +46,7 @@
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"
class="ui:rounded-none ui:font-semibold ui:border-[var(--landing-accent-fg)] ui:text-[var(--landing-accent-fg)] ui:bg-transparent ui:hover:bg-[var(--landing-accent-fg)]/10 ui:hover:text-[var(--landing-accent-fg)]"
>
{authAction.label} →
</Button>
@@ -6,8 +6,9 @@
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TerminalNav from './terminal/nav.svelte';
import TerminalHero from './terminal/hero.svelte';
import TerminalCourseCard from './terminal/course-card.svelte';
import { Button } from '../../base/button';
import { BlurFade } from '../animation/blurfade';
import { themeStyle } from './theme-style';
import { getCourseTypeLandingMeta } from './landing-page-utils';
let {
@@ -22,12 +23,13 @@
embed,
callout,
links,
footer
footer,
labels
}: OrgLandingPageProps = $props();
function priceLabel(course: CourseItem): string {
if (course.price) return course.price;
if (!course.cost) return 'Free';
if (!course.cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', {
style: 'currency',
@@ -43,7 +45,7 @@
);
const tabs = $derived<{ key: TabKey; label: string }[]>([
{ key: 'all', label: 'All' },
{ key: 'all', label: labels?.filterAllLabel ?? 'All' },
...courseTypeKeys.map((type) => {
const meta = getCourseTypeLandingMeta({ id: '', title: '', description: '', type } as CourseItem);
@@ -69,22 +71,8 @@
</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;
"
class="ui:min-h-screen ui:font-sans ui:w-full ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)]"
style={themeStyle('terminal')}
>
<main>
<TerminalHero {orgName} {hero} {courses}>
@@ -95,109 +83,59 @@
{#if courses.length > 0}
<section
class="ui:py-24 ui:px-6"
style="border-top: 1px solid var(--terminal-line); background: var(--terminal-bg);"
class="ui:py-24 ui:px-6 ui:bg-[var(--landing-bg)]"
style="border-top: 1px solid var(--landing-border-soft);"
>
<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>
<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(--landing-accent);"
>
<span
class="ui:size-1.5 ui:rounded-full"
style="background: var(--landing-accent); box-shadow: 0 0 12px var(--landing-accent);"
></span>
{labels?.catalogEyebrow ?? 'Catalog'}
</p>
<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>
<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] ui:text-[var(--landing-fg)]"
>
{labels?.catalogHeading ?? 'Programs your team can actually finish.'}
</h2>
<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>
<p class="ui:text-[15px] ui:max-w-xl ui:m-0 ui:mb-8 ui:text-[var(--landing-fg-muted)]">
{labels?.catalogDescription ??
'Tracks built around real-world artifacts — pull requests, postmortems, and runbooks. No filler.'}
</p>
{#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>
<div
class="ui:inline-flex ui:items-center ui:gap-0.5 ui:p-1 ui:rounded-full ui:mb-8 ui:bg-[var(--landing-card)]"
style="border: 1px solid var(--landing-border);"
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 ui:cursor-pointer"
style={isActive
? 'background: var(--landing-fg); color: var(--landing-bg);'
: 'background: transparent; color: var(--landing-fg-muted);'}
>
{tab.label}
</button>
{/each}
</div>
{/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>
<TerminalCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
@@ -207,9 +145,9 @@
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"
class="ui:rounded-full ui:px-5 ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/5"
>
View all programs →
{labels?.browseCoursesLabel ?? 'View all programs →'}
</Button>
</div>
{/if}
@@ -218,11 +156,11 @@
{/if}
</main>
<OrgLandingPageLinks {links} variant="terminal" />
<OrgLandingPageLinks {links} {labels} variant="terminal" />
<OrgLandingPageEmbed {embed} variant="terminal" />
<OrgLandingPageEmbed {embed} {labels} variant="terminal" />
<OrgLandingPageCallout {callout} variant="terminal" />
<OrgLandingPageCallout {callout} {labels} variant="terminal" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
</div>
@@ -0,0 +1,72 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function slugify(c: CourseItem): string {
if (c.slug) return c.slug;
return c.title
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function priceLabel(c: CourseItem): string {
if (c.price) return c.price;
if (!c.cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: c.currency ?? 'USD',
maximumFractionDigits: 0
}).format(c.cost);
}
</script>
<a
{href}
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 ui:text-[var(--landing-fg)] {disableCourseLinks
? ''
: 'ui:cursor-pointer'}"
style="
background: linear-gradient(180deg, var(--landing-card) 0%, var(--landing-bg-section) 100%);
border: 1px solid var(--landing-border-soft);
min-height: 180px;
cursor: {disableCourseLinks ? 'default' : 'pointer'};
"
onmouseenter={(e) => !disableCourseLinks && (e.currentTarget.style.borderColor = 'var(--landing-border)')}
onmouseleave={(e) => (e.currentTarget.style.borderColor = 'var(--landing-border-soft)')}
>
<span class="ui:font-mono ui:text-[11px] ui:tracking-[0.04em]" style="color: var(--landing-accent);">
$ {slugify(course)}
</span>
<h3
class="ui:text-[17px] ui:font-semibold ui:leading-snug ui:m-0 ui:text-[var(--landing-fg)]"
style="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 ui:text-[var(--landing-fg-muted)]">
{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(--landing-border-soft);"
>
<span class="ui:text-[var(--landing-fg)]">{priceLabel(course)}</span>
<span aria-hidden="true" class="ui:text-[var(--landing-fg-muted)]">→</span>
</div>
</a>
@@ -2,17 +2,16 @@
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'];
orgName?: OrgLandingPageProps['orgName'];
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
navigation: Snippet;
}
let { orgName, hero, courses, navigation }: Props = $props();
let { orgName = '', hero, courses = [], navigation }: Props = $props();
const programsCount = $derived(courses.length);
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
@@ -63,221 +62,188 @@
<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;
"
class="ui:relative ui:overflow-hidden ui:px-6 ui:pt-16 ui:pb-16 ui:md:pt-20 ui:md:pb-20 ui:bg-[var(--landing-bg)]"
>
<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%);"
style="background: radial-gradient(ellipse 60% 70% at 50% 30%, color-mix(in oklab, var(--landing-accent) 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>
<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(--landing-accent) 60%, #ffffff) 0%,
color-mix(in oklab, var(--landing-accent) 35%, transparent) 45%,
color-mix(in oklab, var(--landing-bg) 70%, transparent) 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 8px 60px color-mix(in oklab, var(--landing-accent) 18%, transparent);
user-select: none;
"
>
{hero.heading}
</h1>
<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}
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
{#if hero.secondaryAction}
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
href={hero.secondaryAction.href}
size="sm"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
variant="outline"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-white/5 ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/10"
>
{hero.primaryAction.label}
{hero.secondaryAction.label}
</Button>
</div>
</BlurFade>
{/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 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>
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto ui:text-[var(--landing-fg-muted)]">
{hero.subheading}
</p>
{#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] ui:text-[var(--landing-fg-muted)]"
style="
border: 1px solid var(--landing-border);
background: rgba(255,255,255,0.02);
backdrop-filter: blur(8px);
"
>
<span style="color: var(--landing-accent);">&gt;</span>
<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 class="ui:font-medium ui:text-[var(--landing-fg)]">{programsCount}</span>
{programsCount === 1 ? 'program' : 'programs'} running</span
>
<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 learnersCount > 0}
<span class="ui:inline-block ui:w-px ui:h-3 ui:bg-[var(--landing-border)]"></span>
<span><span class="ui:font-medium ui:text-[var(--landing-fg)]">{learnersFormatted}</span> learners</span>
{/if}
{#if tracksCount > 1}
<span class="ui:inline-block ui:w-px ui:h-3 ui:bg-[var(--landing-border)]"></span>
<span><span class="ui:font-medium ui:text-[var(--landing-fg)]">{tracksCount}</span> tracks</span>
{/if}
</span>
{/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 ui:bg-[var(--landing-card)]"
style="
border: 1px solid var(--landing-border);
box-shadow:
0 30px 90px -30px rgba(0,0,0,0.7),
0 0 0 1px color-mix(in oklab, var(--landing-accent) 6%, transparent);
"
>
<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);
"
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3 ui:bg-[var(--landing-card-soft)]"
style="border-bottom: 1px solid var(--landing-border-soft);"
>
<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 ui:text-[var(--landing-fg-muted)]"
>
<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" />
{orgSlug}/preview
</span>
</div>
</BlurFade>
<img src={hero.image} alt={orgName} class="ui:block ui:w-full ui:h-auto" />
</div>
{: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 ui:bg-[var(--landing-card)]"
style="
border: 1px solid var(--landing-border);
box-shadow:
0 30px 90px -30px rgba(0,0,0,0.7),
0 0 0 1px color-mix(in oklab, var(--landing-accent) 6%, transparent);
"
>
<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);
"
class="ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3 ui:bg-[var(--landing-card-soft)]"
style="border-bottom: 1px solid var(--landing-border-soft);"
>
<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 ui:text-[var(--landing-fg-muted)]"
>
<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>
{orgSlug}/main · catalog
</span>
<span class="ui:font-mono ui:text-[11px] ui:text-[var(--landing-fg-faint)]">
{programsCount}
{programsCount === 1 ? 'program' : 'programs'}
</span>
</div>
<div class="ui:p-5 ui:font-mono ui:text-[13px] ui:text-[var(--landing-fg)]" style="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(--landing-border-soft);"
>
<span style="color: var(--landing-accent);">&gt;</span>
<span>list programs --org {orgSlug}</span>
</div>
<div class="ui:py-1 ui:text-[12px] ui:text-[var(--landing-fg-faint)]">
● 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 ui:text-[var(--landing-fg)]">
<span class="ui:text-[var(--landing-fg-faint)]">{row.n}</span>
<span class="ui:truncate ui:flex-1" style="color: var(--landing-accent);">{row.slug}</span>
<span class="ui:text-[12px] ui:text-[var(--landing-fg-muted)]">{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(--landing-accent);">&gt;</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>
class="ui:inline-block ui:w-2 ui:h-[14px] ui:align-middle"
style="background: var(--landing-accent); animation: terminal-caret 1s steps(1) infinite;"
aria-hidden="true"
></span>
</div>
</div>
</BlurFade>
<div
class="ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2.5 ui:font-mono ui:text-[11px] ui:bg-[var(--landing-bg-section)] ui:text-[var(--landing-fg-faint)]"
style="border-top: 1px solid var(--landing-border-soft);"
>
<div class="ui:flex ui:gap-4">
<span>Enter enroll</span>
<span>↑/↓ navigate</span>
<span>^q quit</span>
</div>
<div class="ui:text-[var(--landing-fg-muted)]">{orgSlug}-cli</div>
</div>
</div>
{/if}
</div>
</section>
@@ -17,15 +17,14 @@
</script>
<EditableLandingSection sectionKey="navigation">
<header class="ui:relative ui:z-50 ui:px-6" style="background: transparent;">
<header class="ui:relative ui:z-50 ui:px-6 ui:bg-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);"
class="ui:flex ui:items-center ui:gap-2 ui:no-underline ui:text-[var(--landing-fg)] ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-6 ui:w-auto" />
@@ -47,16 +46,12 @@
{#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)')}
class="ui:text-[11px] ui:font-medium ui:tracking-[0.12em] ui:uppercase ui:no-underline ui:transition-colors ui:text-[var(--landing-fg-muted)] ui:hover:text-[var(--landing-fg)] ui:cursor-pointer"
>
{item.label}
</a>
{/each}
<span class="ui:select-none" style="color: var(--terminal-ink-faint, #3d4049);" aria-hidden="true">/</span
>
<span class="ui:select-none ui:text-[var(--landing-fg-faint)]" aria-hidden="true">/</span>
</nav>
{/if}
</div>
@@ -67,7 +62,7 @@
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"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-transparent ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/5"
>
{authAction.label}
</Button>
@@ -0,0 +1,110 @@
import type { OrgLandingPageTheme } from './types';
export type LandingThemeVarName =
| '--landing-bg'
| '--landing-bg-section'
| '--landing-card'
| '--landing-card-soft'
| '--landing-fg'
| '--landing-fg-muted'
| '--landing-fg-faint'
| '--landing-border'
| '--landing-border-soft'
| '--landing-accent'
| '--landing-accent-fg'
| '--landing-button-primary-bg'
| '--landing-button-primary-fg'
| '--landing-button-primary-bg-hover'
| '--landing-button-secondary-bg'
| '--landing-button-secondary-fg'
| '--landing-button-secondary-border'
| '--landing-button-secondary-bg-hover'
| '--landing-button-tertiary-fg'
| '--landing-button-tertiary-bg-hover';
export type LandingThemeVars = Record<LandingThemeVarName, string>;
const baseTokenVars: LandingThemeVars = {
'--landing-bg': 'var(--background)',
'--landing-bg-section': 'var(--muted)',
'--landing-card': 'var(--card)',
'--landing-card-soft': 'var(--muted)',
'--landing-fg': 'var(--foreground)',
'--landing-fg-muted': 'var(--muted-foreground)',
'--landing-fg-faint': 'var(--muted-foreground)',
'--landing-border': 'var(--border)',
'--landing-border-soft': 'var(--border)',
'--landing-accent': 'var(--primary)',
'--landing-accent-fg': 'var(--primary-foreground)',
'--landing-button-primary-bg': 'var(--primary)',
'--landing-button-primary-fg': 'var(--primary-foreground)',
'--landing-button-primary-bg-hover': 'color-mix(in oklab, var(--primary) 88%, black)',
'--landing-button-secondary-bg': 'transparent',
'--landing-button-secondary-fg': 'var(--foreground)',
'--landing-button-secondary-border': 'var(--border)',
'--landing-button-secondary-bg-hover': 'var(--muted)',
'--landing-button-tertiary-fg': 'var(--primary)',
'--landing-button-tertiary-bg-hover': 'var(--muted)'
};
export const LANDING_THEME_VARS: Record<OrgLandingPageTheme, LandingThemeVars> = {
minimal: baseTokenVars,
bold: baseTokenVars,
classic: baseTokenVars,
saas: baseTokenVars,
tech: baseTokenVars,
studio: baseTokenVars,
corporate: baseTokenVars,
vibrant: baseTokenVars,
terminal: {
'--landing-bg': '#06070a',
'--landing-bg-section': '#0c0e13',
'--landing-card': '#0f1218',
'--landing-card-soft': '#14171f',
'--landing-fg': '#e9eaed',
'--landing-fg-muted': '#9da1ab',
'--landing-fg-faint': '#61656f',
'--landing-border': '#1c1f28',
'--landing-border-soft': '#161922',
'--landing-accent': 'var(--primary)',
'--landing-accent-fg': 'var(--primary-foreground)',
'--landing-button-primary-bg': '#f4f5f7',
'--landing-button-primary-fg': '#0a0b0e',
'--landing-button-primary-bg-hover': '#ffffff',
'--landing-button-secondary-bg': 'rgba(255,255,255,0.04)',
'--landing-button-secondary-fg': '#e9eaed',
'--landing-button-secondary-border': '#262a35',
'--landing-button-secondary-bg-hover': 'rgba(255,255,255,0.08)',
'--landing-button-tertiary-fg': 'var(--primary)',
'--landing-button-tertiary-bg-hover': 'rgba(255,255,255,0.06)'
},
editorial: {
'--landing-bg': '#f4f3ed',
'--landing-bg-section': '#ebeae3',
'--landing-card': '#fafaf5',
'--landing-card-soft': '#ecebe5',
'--landing-fg': '#1a1a1a',
'--landing-fg-muted': '#76746c',
'--landing-fg-faint': '#98968e',
'--landing-border': '#d9d8d0',
'--landing-border-soft': '#e3e2da',
'--landing-accent': '#c25237',
'--landing-accent-fg': '#fafaf5',
'--landing-button-primary-bg': '#1a1a1a',
'--landing-button-primary-fg': '#fafaf5',
'--landing-button-primary-bg-hover': '#2c2b29',
'--landing-button-secondary-bg': 'transparent',
'--landing-button-secondary-fg': '#1a1a1a',
'--landing-button-secondary-border': '#d9d8d0',
'--landing-button-secondary-bg-hover': '#ebeae3',
'--landing-button-tertiary-fg': '#c25237',
'--landing-button-tertiary-bg-hover': '#ebeae3'
}
};
export function themeStyle(theme: OrgLandingPageTheme): string {
const vars = LANDING_THEME_VARS[theme] ?? baseTokenVars;
return Object.entries(vars)
.map(([name, value]) => `${name}: ${value}`)
.join('; ');
}
@@ -8,7 +8,9 @@ export type OrgLandingPageTheme =
| 'tech'
| 'studio'
| 'corporate'
| 'terminal';
| 'terminal'
| 'editorial'
| 'vibrant';
export type CourseItem = {
id: string;
@@ -122,6 +124,48 @@ export type LandingPageLinks = {
cards: LandingPageLinkCard[];
};
/**
* Localizable UI chrome strings used across landing templates.
* Every field is optional; when absent, each template falls back to its own
* English default that fits the template's voice. Consumers (e.g. dashboard
* with translations) pass an object of resolved strings to override defaults
* in one place rather than per template.
*/
export interface OrgLandingPageLabels {
/** Eyebrow above the catalog section. Default per template (often "Catalog"). */
catalogEyebrow?: string;
/** Section heading for the catalog. Default per template (e.g. "Our Courses", "Programs starting this season."). */
catalogHeading?: string;
/** Optional description under the catalog heading. Default per template, only used where the template renders one. */
catalogDescription?: string;
/** Label for the catalog "browse all"/"view more" CTA. Default per template (e.g. "Browse all →", "View more courses"). */
browseCoursesLabel?: string;
/** Per-card CTA on a course card. Default: "Enroll" (Bold defaults to "Details"). */
enrollLabel?: string;
/** Displayed in place of the price when cost is 0. Default: "Free". */
freeLabel?: string;
/** Tag/badge prefix for a featured course (editorial hero, vibrant featured panel). Default: "Featured". */
featuredLabel?: string;
/** Filter chip label for "show all" in templates that render a filter bar. Default: "All". */
filterAllLabel?: string;
/** CTA for the featured-course block (vibrant). Default: "Start this course". */
startCourseLabel?: string;
/** Plural-aware lesson-count label. Default: `${count} lessons` / `1 lesson`. */
lessonsLabel?: (count: number) => string;
/** Plural-aware exercise-count label. Default: `${count} exercises` / `1 exercise`. */
exercisesLabel?: (count: number) => string;
/** Plural-aware enrolled-students label. Default: `${count} enrolled`. */
enrolledLabel?: (count: number) => string;
/** Eyebrow for the Resources/Links section. Default per template (typically "Resources"). */
resourcesEyebrow?: string;
/** Eyebrow for the Embed section in templates that render one (terminal). Default: "Embed". */
embedEyebrow?: string;
/** Eyebrow for the Callout section. Default per template (e.g. "Ready when you are", "Get started"). */
calloutEyebrow?: string;
/** Card footer CTA on the resources/links section. Default per template ("Learn more", "Visit"). Separate from the existing per-template `boldVisitLabel` / `classicLearnMoreLabel` on `LandingPageLinks`, which still apply to those specific templates. */
learnMoreLabel?: string;
}
export interface OrgLandingPageProps {
orgName: string;
logoUrl?: string;
@@ -145,4 +189,6 @@ export interface OrgLandingPageProps {
callout?: LandingPageCallout;
links?: LandingPageLinks;
footer: OrgLandingPageFooterConfig;
/** Optional UI-chrome string overrides (eyebrows, CTA labels, plural-aware meta). */
labels?: OrgLandingPageLabels;
}
@@ -0,0 +1,216 @@
<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 VibrantNav from './vibrant/nav.svelte';
import VibrantHero from './vibrant/hero.svelte';
import VibrantCourseCard from './vibrant/course-card.svelte';
import { themeStyle } from './theme-style';
import { Button } from '../../base/button';
import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from './landing-page-utils';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
courses,
hasMoreCourses = false,
disableCourseLinks = false,
embed,
callout,
links,
footer,
labels
}: OrgLandingPageProps = $props();
const featured = $derived(courses[0]);
const featuredTypeMeta = $derived(featured ? getCourseTypeLandingMeta(featured) : undefined);
const remainingCourses = $derived(courses.slice(1));
const resolvedLessonsLabel = $derived(labels?.lessonsLabel ?? defaultLessonsLabel);
const resolvedExercisesLabel = $derived(labels?.exercisesLabel ?? defaultExercisesLabel);
function formatCost(cost?: number, currency = 'USD'): string {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
}
function courseHref(course: CourseItem): string | undefined {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
}
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('vibrant')}
>
<main>
<VibrantHero {hero} {labels}>
{#snippet navigation()}
<VibrantNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</VibrantHero>
{#if featured}
<section
class="ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-32"
>
<div class="ui:max-w-[1320px] ui:mx-auto">
<div class="ui:grid ui:grid-cols-1 ui:lg:grid-cols-[1.15fr_1fr] ui:gap-12 ui:lg:gap-20 ui:items-center">
<div>
{#if featuredTypeMeta}
<span
class="ui:inline-flex ui:items-center ui:gap-1.5 ui:rounded-full ui:px-3 ui:py-1 ui:text-[13px] ui:font-medium ui:mb-6 ui:text-[var(--landing-accent-fg)]"
style="background: rgba(255,255,255,0.18);"
>
{labels?.featuredLabel ?? 'Featured'} · {featuredTypeMeta.label}
</span>
{/if}
<h2
class="ui:text-4xl ui:md:text-5xl ui:lg:text-[56px] ui:font-medium ui:tracking-tight ui:m-0 ui:mb-6 ui:max-w-[520px]"
style="line-height: 1.05; letter-spacing: -0.025em;"
>
{featured.title}
</h2>
<p class="ui:text-lg ui:m-0 ui:mb-4 ui:max-w-[480px] ui:opacity-90" style="line-height: 1.5;">
{featured.description}
</p>
<div
class="ui:flex ui:flex-wrap ui:items-center ui:gap-x-5 ui:gap-y-2 ui:text-[14.5px] ui:mb-8 ui:opacity-85"
>
{#if featured.lessonCount}
<span>{resolvedLessonsLabel(featured.lessonCount)}</span>
{/if}
{#if featured.exerciseCount}
<span aria-hidden="true">·</span>
<span>{resolvedExercisesLabel(featured.exerciseCount)}</span>
{/if}
{#if featured.duration}
<span aria-hidden="true">·</span>
<span>{featured.duration}</span>
{/if}
</div>
<div class="ui:flex ui:flex-wrap ui:items-center ui:gap-3">
<Button
href={courseHref(featured)}
disabled={disableCourseLinks}
class="ui:rounded-md ui:px-6 ui:py-3 ui:text-base ui:font-medium ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:hover:opacity-90"
>
{labels?.startCourseLabel ?? 'Start this course'}
</Button>
<span class="ui:text-base ui:font-medium ui:opacity-95">
{featured.price || formatCost(featured.cost, featured.currency)}
</span>
</div>
</div>
<div
class="ui:relative ui:overflow-hidden ui:rounded-3xl ui:h-[440px] ui:md:h-[480px]"
style="
background:
radial-gradient(50% 60% at 40% 40%, rgba(255,255,255,0.55) 0%, transparent 70%),
radial-gradient(60% 70% at 70% 65%, rgba(255,255,255,0.32) 0%, transparent 70%),
radial-gradient(40% 50% at 25% 75%, rgba(255,235,210,0.5) 0%, transparent 70%),
linear-gradient(135deg, #b0a8a0 0%, #d4cabd 50%, #c8c0b5 100%);
"
>
<div
class="ui:absolute ui:left-1/2 ui:top-1/2 ui:w-[80%] ui:overflow-hidden ui:rounded-2xl ui:p-6"
style="
transform: translate(-50%, -50%);
background: #0c1320;
color: #fff;
box-shadow: 0 30px 60px -20px rgba(0,0,0,0.35);
"
>
<p class="ui:m-0 ui:mb-1 ui:text-[16px] ui:font-semibold">Course outline</p>
<p class="ui:m-0 ui:mb-5 ui:text-[13px]" style="color: #8a93a0;">
Pick up where you left off — anytime.
</p>
<div class="ui:flex ui:flex-col ui:gap-2">
{#each Array.from({ length: Math.min(featured.lessonCount ?? 4, 4) }) as _, idx (idx)}
<div
class="ui:flex ui:items-center ui:gap-3 ui:rounded-lg ui:px-3.5 ui:py-3"
style="background: #131c2c;"
>
<span
class="ui:inline-flex ui:items-center ui:justify-center ui:size-6 ui:rounded ui:text-[11px] ui:font-semibold ui:tabular-nums"
style="background: #2a3548; color: #fff;"
>
{String(idx + 1).padStart(2, '0')}
</span>
<span class="ui:flex-1 ui:text-[13.5px] ui:font-medium" style="color: #fff;">
{idx === 0
? 'Foundations & setup'
: idx === 1
? 'Core concepts'
: idx === 2
? 'Hands-on practice'
: 'Capstone project'}
</span>
<span class="ui:text-[11.5px] ui:tabular-nums" style="color: #6c7585;">
{idx === 0 ? '42 min' : idx === 1 ? '1h 12m' : idx === 2 ? '58 min' : '2h 30m'}
</span>
</div>
{/each}
</div>
</div>
</div>
</div>
</div>
</section>
{/if}
{#if remainingCourses.length > 0 || (!featured && courses.length > 0)}
{@const catalogCourses = featured ? remainingCourses : courses}
<section id="courses" class="ui:px-6 ui:md:px-8 ui:py-24 ui:md:py-28 ui:bg-[var(--landing-bg)]">
<div class="ui:max-w-[1320px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:justify-between ui:gap-6 ui:mb-12">
<div>
<p
class="ui:text-[13px] ui:font-medium ui:tracking-[0.08em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:m-0 ui:mb-3"
>
{labels?.catalogEyebrow ?? 'Catalog'}
</p>
<h2
class="ui:text-4xl ui:md:text-5xl ui:font-medium ui:tracking-tight ui:m-0 ui:text-[var(--landing-fg)] ui:max-w-2xl"
style="line-height: 1.05; letter-spacing: -0.025em;"
>
{labels?.catalogHeading ?? 'More courses to take next.'}
</h2>
</div>
{#if hasMoreCourses}
<Button
href={disableCourseLinks ? undefined : '/courses'}
variant="outline"
class="ui:rounded-md ui:px-5 ui:py-2.5 ui:font-medium ui:border-[var(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-[var(--landing-card-soft)]"
disabled={disableCourseLinks}
>
{labels?.browseCoursesLabel ?? 'Browse all →'}
</Button>
{/if}
</div>
<div class="ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:lg:grid-cols-3 ui:gap-5">
{#each catalogCourses as course, index (course.id)}
<VibrantCourseCard {course} {disableCourseLinks} {labels} />
{/each}
</div>
</div>
</section>
{/if}
</main>
<OrgLandingPageLinks {links} {labels} variant="vibrant" />
<OrgLandingPageEmbed {embed} {labels} variant="vibrant" />
<OrgLandingPageCallout {callout} {labels} variant="vibrant" />
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="vibrant" />
</div>
@@ -0,0 +1,86 @@
<script lang="ts">
import type { CourseItem, OrgLandingPageLabels } from '../types';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
interface Props {
course: CourseItem;
disableCourseLinks?: boolean;
labels?: OrgLandingPageLabels;
}
let { course, disableCourseLinks = false, labels }: Props = $props();
const courseTypeMeta = $derived(getCourseTypeLandingMeta(course));
const href = $derived.by(() => {
if (disableCourseLinks) return undefined;
return course.link || (course.slug ? `/course/${course.slug}` : undefined);
});
function formatCost(cost?: number, currency = 'USD'): string {
if (!cost) return labels?.freeLabel ?? 'Free';
return new Intl.NumberFormat('en-US', { style: 'currency', currency, maximumFractionDigits: 0 }).format(cost);
}
</script>
<a
{href}
class="ui:group ui:flex ui:flex-col ui:h-full ui:overflow-hidden ui:rounded-[20px] ui:bg-[var(--landing-card-soft)] ui:no-underline ui:transition-colors ui:hover:bg-[var(--landing-card)] {disableCourseLinks
? 'ui:cursor-default'
: 'ui:cursor-pointer'}"
aria-disabled={disableCourseLinks}
tabindex={disableCourseLinks ? -1 : undefined}
>
{#if course.logo}
<div
class="ui:h-[180px] ui:bg-cover ui:bg-center"
style="background-image: url('{course.logo}');"
aria-hidden="true"
></div>
{:else}
<div
class="ui:h-[180px] ui:relative ui:overflow-hidden"
style="
background:
radial-gradient(60% 80% at 30% 30%, color-mix(in oklab, var(--landing-accent) 40%, white) 0%, transparent 65%),
linear-gradient(135deg, color-mix(in oklab, var(--landing-accent) 70%, white) 0%, color-mix(in oklab, var(--landing-accent) 30%, white) 100%);
"
aria-hidden="true"
></div>
{/if}
<div class="ui:flex ui:flex-col ui:flex-1 ui:p-7">
{#if courseTypeMeta}
<span
class="ui:text-[12px] ui:font-medium ui:tracking-[0.04em] ui:uppercase ui:text-[var(--landing-fg-muted)] ui:mb-3"
>
{courseTypeMeta.label}
</span>
{/if}
<h3
class="ui:text-xl ui:font-semibold ui:tracking-tight ui:m-0 ui:mb-2.5 ui:text-[var(--landing-fg)]"
style="letter-spacing: -0.015em;"
>
{course.title}
</h3>
<p
class="ui:text-[15px] ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:line-clamp-3 ui:m-0 ui:mb-5 ui:flex-1"
>
{course.description}
</p>
<div
class="ui:flex ui:items-center ui:justify-between ui:pt-5 ui:mt-auto"
style="border-top: 1px solid var(--landing-border-soft);"
>
<span class="ui:text-lg ui:font-semibold ui:text-[var(--landing-fg)]" style="letter-spacing: -0.01em;">
{course.price || formatCost(course.cost, course.currency)}
</span>
<span
class="ui:inline-flex ui:items-center ui:gap-1 ui:text-sm ui:font-medium ui:text-[var(--landing-accent)] ui:transition-transform ui:group-hover:translate-x-0.5"
aria-hidden="true"
>
{labels?.enrollLabel ?? 'Enroll'} <span>→</span>
</span>
</div>
</div>
</a>
@@ -0,0 +1,72 @@
<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 EditableLandingSection from '../editable-section.svelte';
interface Props {
hero: OrgLandingPageProps['hero'];
labels?: OrgLandingPageProps['labels'];
navigation: Snippet;
}
let { hero, labels, navigation }: Props = $props();
</script>
{@render navigation()}
<EditableLandingSection sectionKey="hero">
<section
class="ui:relative ui:px-6 ui:md:px-8 ui:pt-20 ui:md:pt-24 ui:pb-20 ui:md:pb-24 ui:bg-[var(--landing-bg)] ui:text-center"
>
<div class="ui:max-w-[1320px] ui:mx-auto">
<h1
class="ui:text-[64px] ui:md:text-[88px] ui:lg:text-[110px] ui:font-medium ui:tracking-tight ui:text-[var(--landing-fg)] ui:m-0 ui:mb-7 ui:max-w-[1100px] ui:mx-auto ui:text-balance"
style="line-height: 0.98; letter-spacing: -0.04em;"
>
{hero.heading}
</h1>
<p
class="ui:text-lg ui:md:text-xl ui:lg:text-[22px] ui:text-[var(--landing-fg-muted)] ui:max-w-[640px] ui:mx-auto ui:m-0 ui:mb-10"
style="line-height: 1.4; letter-spacing: -0.012em;"
>
{hero.subheading}
</p>
<div class="ui:inline-flex ui:flex-wrap ui:justify-center ui:items-center ui:gap-3">
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
class="ui:rounded-md ui:px-7 ui:py-3.5 ui:text-base ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="vibrant"
/>
{/if}
</div>
</div>
</section>
<div
aria-hidden="true"
class="ui:h-[180px] ui:md:h-[220px]"
style="
background: linear-gradient(
to bottom,
var(--landing-bg) 0%,
var(--landing-bg) 18%,
color-mix(in oklab, var(--landing-accent) 12%, var(--landing-bg)) 38%,
color-mix(in oklab, var(--landing-accent) 40%, var(--landing-bg)) 58%,
color-mix(in oklab, var(--landing-accent) 78%, var(--landing-bg)) 78%,
var(--landing-accent) 100%
);
"
></div>
</EditableLandingSection>
@@ -0,0 +1,71 @@
<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-[var(--landing-bg)]">
<div class="ui:max-w-[1320px] ui:mx-auto ui:px-6 ui:md:px-8">
<div class="ui:flex ui:items-center ui:justify-between ui:h-[72px] ui:gap-6">
<div class="ui:flex ui:items-center ui:gap-8">
<a
href="/"
class="ui:inline-flex ui:items-center ui:gap-2 ui:font-semibold ui:text-[22px] ui:tracking-tight ui:text-[var(--landing-fg)] ui:no-underline ui:cursor-pointer"
>
{#if logoUrl}
<img src={logoUrl} alt={orgName} class="ui:h-7 ui:w-auto" />
{:else}
<span class="ui:relative ui:inline-block ui:size-[22px]" aria-hidden="true">
<span
class="ui:absolute ui:left-0 ui:top-0 ui:w-[10px] ui:h-[22px] ui:rounded-[3px] ui:bg-[var(--landing-accent)]"
></span>
<span
class="ui:absolute ui:right-0 ui:top-[6px] ui:w-[10px] ui:h-[10px] ui:rounded-[3px] ui:bg-[var(--landing-accent)]"
></span>
</span>
{/if}
<span>{orgName}</span>
</a>
{#if navItems.length > 0}
<nav class="ui:hidden ui:md:flex ui:gap-1 ui:items-center">
{#each navItems as item (item.href + item.label)}
<a
href={item.href}
class="ui:inline-flex ui:items-center ui:gap-1 ui:px-3.5 ui:py-2 ui:text-[15px] ui:font-medium ui:text-[var(--landing-fg)] ui:rounded-md ui:transition-colors ui:hover:bg-[var(--landing-card-soft)] ui:no-underline ui:cursor-pointer"
>
{item.label}
</a>
{/each}
</nav>
{/if}
</div>
{#if authAction}
<div class="ui:flex ui:items-center ui:gap-4">
<Button
href={authAction.href}
size="sm"
class="ui:rounded-md ui:px-4 ui:font-medium ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)] ui:hover:opacity-90"
>
{authAction.label}
</Button>
</div>
{/if}
</div>
</div>
</header>
</EditableLandingSection>
@@ -16,6 +16,7 @@
const cssVars = $derived(payload ? buildCssVarsFromDesign(payload.design) : '');
const poweredByHref = $derived(payload ? buildPoweredByMarketingUrl(payload) : '');
const customCss = $derived(payload?.design.advanced.customCss?.trim() ?? '');
$effect(() => {
if (!payload || !rootEl) return;
@@ -27,6 +28,9 @@
<div class="cio-widget cio-widget--empty">No widget payload provided.</div>
{:else}
<div bind:this={rootEl} class="cio-widget" style={cssVars} data-cio-layout={payload.layoutType}>
{#if customCss}
<svelte:element this={'style'} data-cio-widget-custom-css="">{customCss}</svelte:element>
{/if}
{#if payload.layoutType === 'card_grid'}
<CardGrid {payload} />
{:else if payload.layoutType === 'tag_filter'}
File diff suppressed because one or more lines are too long
@@ -120,3 +120,8 @@ export const ZYouTubeMetadataQuery = z.object({
url: z.string().url()
});
export type TYouTubeMetadataQuery = z.infer<typeof ZYouTubeMetadataQuery>;
export const ZAssetThumbnailSelect = z.object({
thumbnailUrl: z.string().url()
});
export type TAssetThumbnailSelect = z.infer<typeof ZAssetThumbnailSelect>;