feat: HLS streaming, transcripts, agent course generation & landing-page course previews (#663)

New features:
- HLS video encoding/streaming (hls routes, hls-encoder) and a media
  transcripts pipeline (transcripts route + media-transcript queries)
- AI assistant chat vs. run model: agent-course-generation worker/queue,
  run-monitor pane, chat-changes card, content ask-ai bar
- Per-theme course-page previews across org landing-page themes (bold,
  classic, corporate, editorial, etc.) with a shared course token/component set
- View-as-student mode and login-link / link-invite invite flows

Refactor:
- Extract packages/core consolidating config (env, storage, dashboard-url),
  services and utils previously duplicated under apps/api/src/config
- Remove legacy agent service modules (chat-tools, chat-context,
  context-window, document, fetch-url, lesson-content)
- Relax Cross-Origin-Resource-Policy to allow cross-origin media embedding
- Add format script to tenant-router so turbo run format covers it
This commit is contained in:
Rotimi Best
2026-06-07 18:43:05 +01:00
committed by GitHub
parent a2e70779a0
commit d5b07d356e
496 changed files with 19050 additions and 8312 deletions
@@ -1,6 +1,13 @@
<script module lang="ts">
let pending: Promise<typeof import('../../../editor')> | null = null;
function loadEditor() {
pending ??= import('../../../editor');
return pending;
}
</script>
<script lang="ts">
import type { ExerciseQuestionRendererProps } from '@cio/question-types';
import { Editor } from '../../../editor';
let { question, answer = null }: ExerciseQuestionRendererProps = $props();
@@ -8,5 +15,9 @@
</script>
<div class="ui:space-y-2">
<Editor {content} editable={false} showToolBar={false} editorClass="ui:h-40" />
{#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-40 ui:w-full" />
{:then { Editor }}
<Editor {content} editable={false} showToolBar={false} editorClass="ui:h-40" />
{/await}
</div>
@@ -1,3 +1,11 @@
<script module lang="ts">
let pending: Promise<typeof import('../../../editor')> | null = null;
function loadEditor() {
pending ??= import('../../../editor');
return pending;
}
</script>
<script lang="ts">
import { onDestroy } from 'svelte';
import type { Editor } from '@tiptap/core';
@@ -7,7 +15,6 @@
getTextareaCharacterLimits,
type ExerciseQuestionRendererProps
} from '@cio/question-types';
import { Editor as EditorComponent } from '../../../editor';
let {
question,
@@ -128,15 +135,19 @@
</script>
<div class="ui:space-y-2">
<EditorComponent
{content}
editable={!disabled}
showToolBar={!disabled}
placeholder={label('textarea.take.placeholder')}
editorClass="ui:h-40"
onContentChange={updateAnswer}
onEditorReady={handleEditorReady}
/>
{#await loadEditor()}
<div class="ui:animate-pulse ui:rounded-md ui:bg-muted ui:h-40 ui:w-full" />
{:then { Editor: EditorComponent }}
<EditorComponent
{content}
editable={!disabled}
showToolBar={!disabled}
placeholder={label('textarea.take.placeholder')}
editorClass="ui:h-40"
onContentChange={updateAnswer}
onEditorReady={handleEditorReady}
/>
{/await}
{#if shouldShowCharacterCount}
<span class="ui:text-muted-foreground ui:block ui:text-right ui:text-xs">
@@ -11,6 +11,18 @@
let { source, options = {}, class: className = '' }: Props = $props();
let plyrRef: ReturnType<typeof PlyrPlayer> | null = $state(null);
/**
* Update the player's poster image after construction. Plyr doesn't
* react to `<video poster>` attribute changes once it has wrapped the
* element; callers (e.g. the manage-thumbnails dialog) invoke this on
* thumbnail selection to refresh the preview.
*/
export function setPoster(url: string): void {
plyrRef?.setPoster(url);
}
const tracks = $derived(source.tracks ?? []);
const isMuse = $derived.by(() => source.type === 'muse' && source.metadata?.svid);
const isGoogleDrive = $derived(source.type === 'google_drive');
@@ -40,6 +52,6 @@
></iframe>
</div>
{:else}
<PlyrPlayer src={source.url} {poster} {options} {tracks} />
<PlyrPlayer bind:this={plyrRef} src={source.url} {poster} hls={source.hls === true} {options} {tracks} />
{/if}
</div>
@@ -16,10 +16,10 @@
</script>
{#if embedUrl}
<div style="position:relative;padding-bottom:51.416579%">
<div class="ui:relative ui:overflow-hidden ui:rounded-md" style="padding-bottom:51.416579%">
<iframe
src={embedUrl}
style="width:100%;height:100%;position:absolute;left:0;top:0"
class="ui:absolute ui:left-0 ui:top-0 ui:h-full ui:w-full"
frameborder="0"
allowfullscreen
title="Muse AI Video"
@@ -5,26 +5,51 @@
import type { MediaPlayerOptions, VideoTextTrack } from '../types';
import { getYoutubeVideoId, isYoutubeUrl } from '../utils';
import { PLYR_DEFAULT_CONTROLS } from './constants';
import { Button } from '../../../base/button';
import type Plyr from 'plyr';
interface Props {
src: string;
poster?: string;
/**
* When true, `src` is an HLS master playlist (.m3u8). The player
* attaches hls.js (or uses native HLS on Safari) instead of setting
* the URL on the `<video>` element directly. Cookie minting (if any)
* happens via `options.onBeforeHlsLoad`.
*/
hls?: boolean;
options?: MediaPlayerOptions;
tracks?: VideoTextTrack[];
}
let { src, poster, options = {}, tracks = [] }: Props = $props();
let { src, poster, hls = false, options = {}, tracks = [] }: Props = $props();
let hlsInstance = $state<import('hls.js').default | null>(null);
let videoElement = $state<HTMLVideoElement | null>(null);
let containerElement = $state<HTMLDivElement | null>(null);
let playerInstance = $state<Plyr | null>(null);
let transcriptButtonElement = $state<HTMLButtonElement | null>(null);
let playbackFailed = $state(false);
let reloadInFlight = $state(false);
let timeupdateCleanup: (() => void) | undefined;
let firstPlayCleanup: (() => void) | undefined;
let transcriptPanelCleanup: (() => void) | undefined;
let mediaErrorCleanup: (() => void) | undefined;
let plyrErrorCleanup: (() => void) | undefined;
let loadedSrc = $state<string | null>(null);
let hlsLoadGeneration = 0;
let hlsPlyrGeneration = 0;
let plyrConstructInFlight = false;
let hasFiredFirstPlay = false;
interface HlsQualityConfig {
default: number;
options: number[];
forced: boolean;
onChange: (height: number) => void;
}
const playsinline = $derived(options.playsinline !== false);
const maxHeight = $derived(options.maxHeight || '400px');
const showControls = $derived(options.controls !== false);
@@ -54,6 +79,52 @@
};
}
function handlePlaybackFailure(): void {
if (playbackFailed) return;
playbackFailed = true;
}
async function handlePlaybackReloadClick(): Promise<void> {
if (reloadInFlight || !options.onPlaybackReload) return;
reloadInFlight = true;
try {
const reloaded = await options.onPlaybackReload();
if (!reloaded) return;
playbackFailed = false;
if (videoElement && !isYouTube) {
loadedSrc = null;
videoElement.load();
}
} finally {
reloadInFlight = false;
}
}
function attachMediaErrorHandler(element: HTMLVideoElement): () => void {
const handler = () => {
handlePlaybackFailure();
};
element.addEventListener('error', handler);
return () => {
element.removeEventListener('error', handler);
};
}
function attachPlyrErrorHandler(player: Plyr): () => void {
const handler = () => {
handlePlaybackFailure();
};
player.on('error', handler);
return () => {
player.off('error', handler);
};
}
function attachTranscriptPanelControl(
player: Plyr,
template: HTMLButtonElement,
@@ -88,13 +159,110 @@
};
}
function teardownPlyrInstance(): void {
timeupdateCleanup?.();
firstPlayCleanup?.();
transcriptPanelCleanup?.();
plyrErrorCleanup?.();
mediaErrorCleanup?.();
timeupdateCleanup = undefined;
firstPlayCleanup = undefined;
transcriptPanelCleanup = undefined;
plyrErrorCleanup = undefined;
mediaErrorCleanup = undefined;
if (!playerInstance) return;
try {
playerInstance.destroy();
} catch {
// Plyr may be mid-teardown if the user closed the dialog.
} finally {
playerInstance = null;
}
}
async function constructPlyrForVideoElement(qualityConfig?: HlsQualityConfig): Promise<void> {
if (!videoElement || playerInstance || plyrConstructInFlight) return;
plyrConstructInFlight = true;
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
try {
// Recheck after the dynamic import resolves — the component may have
// unmounted (or the videoElement bind nulled out) while we awaited.
if (!videoElement || playerInstance) return;
mediaErrorCleanup = attachMediaErrorHandler(videoElement);
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
// Match the YouTube branch: force 16:9 so non-16:9 sources (e.g.
// screen recordings at 4:3 or 5:4) get letterboxed inside the player
// container rather than overflowing it and clipping the controls.
ratio: '16:9',
iconUrl: '/plyr.svg',
iconPrefix: 'plyr',
...(qualityConfig
? {
quality: qualityConfig,
i18n: { qualityLabel: { 0: 'Auto' } }
}
: {})
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
options.onPlayerReady?.(playerInstance);
} finally {
plyrConstructInFlight = false;
}
}
async function initHlsPlyr(hls: import('hls.js').default, loadGeneration: number): Promise<void> {
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
if (hlsPlyrGeneration === loadGeneration) return;
hlsPlyrGeneration = loadGeneration;
const heights = Array.from(new Set(hls.levels.map((level) => level.height).filter((height) => height > 0))).sort(
(a, b) => a - b
);
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls || !videoElement) return;
if (!heights.length) {
await constructPlyrForVideoElement();
return;
}
await constructPlyrForVideoElement({
default: 0,
options: [0, ...heights],
forced: true,
onChange: (height: number) => {
if (hlsInstance !== hls) return;
if (height === 0) {
hls.currentLevel = -1;
return;
}
const levelIndex = hls.levels.findIndex((level) => level.height === height);
if (levelIndex >= 0) hls.currentLevel = levelIndex;
}
});
}
onMount(() => {
void (async () => {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
if (isYouTube && youtubeVideoId && containerElement) {
const PlyrModule = await import('plyr');
const PlyrConstructor = PlyrModule.default;
const controls = showControls ? PLYR_DEFAULT_CONTROLS : [];
playerInstance = new PlyrConstructor(containerElement, {
controls,
autoplay,
@@ -104,57 +272,202 @@
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
plyrErrorCleanup = attachPlyrErrorHandler(playerInstance);
options.onPlayerReady?.(playerInstance);
return;
}
if (videoElement) {
playerInstance = new PlyrConstructor(videoElement, {
controls,
autoplay,
// Native files expose their own width/height — let Plyr fit to them instead of
// forcing 16:9 and showing black letterbox bars around smaller/portrait videos.
iconUrl: '/plyr.svg',
iconPrefix: 'plyr'
});
timeupdateCleanup = attachTimeUpdates(playerInstance);
firstPlayCleanup = attachFirstPlay(playerInstance);
if (options.transcriptPanelControl && transcriptButtonElement) {
transcriptPanelCleanup = attachTranscriptPanelControl(
playerInstance,
transcriptButtonElement,
options.transcriptPanelControl
);
}
options.onPlayerReady?.(playerInstance);
// HLS sources defer Plyr until hls.js parses the manifest — Plyr's
// quality menu must be configured at construction time, and tearing
// the player down after hls.js attaches breaks the video wrapper.
if (!hls && videoElement) {
await constructPlyrForVideoElement();
}
})();
});
onDestroy(() => {
timeupdateCleanup?.();
firstPlayCleanup?.();
transcriptPanelCleanup?.();
if (!playerInstance) return;
/**
* Imperative poster setter. Plyr stores the poster as its own internal
* property after construction, so updating `<video poster>` reactively
* has no visible effect. Callers update the poster via this method.
* Exposed through `bind:this` on `MediaPlayer`.
*/
export function setPoster(url: string): void {
if (!playerInstance || isYouTube) return;
try {
if (playerInstance.isEmbed && playerInstance.playing) {
playerInstance.pause();
}
playerInstance.poster = url;
} catch {
// No-op for player teardown safety.
// Plyr may not have finished its setup yet — caller can retry.
}
}
$effect(() => {
const element = videoElement;
const nextSrc = src;
if (!element || isYouTube || !nextSrc || nextSrc === loadedSrc) return;
loadedSrc = nextSrc;
playbackFailed = false;
const onLoadedMetadata = () => {
element.removeEventListener('loadedmetadata', onLoadedMetadata);
options.onSourceLoaded?.(element);
};
element.addEventListener('loadedmetadata', onLoadedMetadata);
if (hls) {
void loadHlsSource(element, nextSrc, onLoadedMetadata);
} else {
element.src = nextSrc;
element.load();
}
});
function destroyHlsInstance(): void {
if (!hlsInstance) return;
try {
playerInstance.destroy();
hlsInstance.destroy();
} catch {
// ignore — hls.js may already be torn down
} finally {
playerInstance = null;
hlsInstance = null;
}
}
async function loadHlsSource(element: HTMLVideoElement, manifestUrl: string, onLoaded: () => void): Promise<void> {
const loadGeneration = ++hlsLoadGeneration;
hlsPlyrGeneration = 0;
destroyHlsInstance();
teardownPlyrInstance();
playbackFailed = false;
if (options.onBeforeHlsLoad) {
try {
await options.onBeforeHlsLoad();
} catch (error) {
// The cookie endpoint may 503 in environments where HLS_SIGNING_SECRET
// isn't configured (local dev / self-hosted). Playback can still
// succeed via the API's session-auth streaming route — so we log and
// proceed rather than fail.
console.warn('onBeforeHlsLoad failed, continuing without cookie', error);
}
}
// Bail if the source changed underneath us while awaiting the cookie.
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
element.removeEventListener('loadedmetadata', onLoaded);
return;
}
// Prefer hls.js over native HLS. Chrome's `canPlayType` returns
// `'maybe'` for `application/vnd.apple.mpegurl` even though it can't
// actually play HLS — taking the native path on Chrome makes the
// `<video>` element fetch the manifest directly with
// `crossorigin="anonymous"`, which strips cookies and trips the api's
// 403. Only fall back to native HLS when hls.js isn't supported
// (real Safari + media-element-only environments).
const HlsModule = await import('hls.js');
const Hls = HlsModule.default;
const FetchLoader = HlsModule.FetchLoader;
if (loadGeneration !== hlsLoadGeneration || loadedSrc !== manifestUrl) {
element.removeEventListener('loadedmetadata', onLoaded);
return;
}
if (!Hls.isSupported()) {
element.src = manifestUrl;
element.load();
void constructPlyrForVideoElement();
return;
}
hlsInstance = new Hls({
enableWorker: true,
// Defer segment fetches until the user presses play. Manifest/variant
// playlist discovery still runs so duration metadata is available.
autoStartLoad: false,
// Use hls.js's fetch-based loader so we can set `credentials: 'include'`
// uniformly on the manifest GET, variant playlist GETs, and segment
// GETs. The default XhrLoader's `xhrSetup` doesn't always fire on
// every request type (some bypass it for the master playlist),
// leaving the request without cookies and the api returning 403.
// FetchLoader respects standard Fetch API credentials.
loader: FetchLoader,
fetchSetup: (context, initParams) => {
initParams.credentials = 'include';
return new Request(context.url, initParams);
}
});
const hls = hlsInstance;
const plyrFallbackTimer = setTimeout(() => {
if (loadGeneration !== hlsLoadGeneration || playerInstance) return;
void constructPlyrForVideoElement();
}, 4_000);
hls.on(Hls.Events.ERROR, (_event, data) => {
if (data.fatal) {
handlePlaybackFailure();
if (!playerInstance) {
void constructPlyrForVideoElement();
}
}
});
hls.on(Hls.Events.MANIFEST_PARSED, () => {
clearTimeout(plyrFallbackTimer);
void initHlsPlyr(hls, loadGeneration);
});
const startBufferingOnPlay = () => {
element.removeEventListener('play', startBufferingOnPlay);
if (loadGeneration !== hlsLoadGeneration || hlsInstance !== hls) return;
hls.startLoad(-1);
};
element.addEventListener('play', startBufferingOnPlay);
hls.loadSource(manifestUrl);
hls.attachMedia(element);
}
$effect(() => {
const player = playerInstance;
const control = options.transcriptPanelControl;
const template = transcriptButtonElement;
if (!player || !control || !template) return;
transcriptPanelCleanup?.();
transcriptPanelCleanup = attachTranscriptPanelControl(player, template, control);
return () => {
transcriptPanelCleanup?.();
transcriptPanelCleanup = undefined;
};
});
onDestroy(() => {
hlsLoadGeneration += 1;
hlsPlyrGeneration = 0;
destroyHlsInstance();
if (playerInstance) {
try {
if (playerInstance.isEmbed && playerInstance.playing) {
playerInstance.pause();
}
} catch {
// No-op for player teardown safety.
}
}
teardownPlyrInstance();
});
</script>
<div
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:bg-muted"
class="ui:relative ui:aspect-video ui:w-full ui:overflow-hidden ui:rounded-md"
style="max-height: {maxHeight}; min-height: {options.minHeight}; height: {options.height};"
>
{#if isYouTube && youtubeVideoId}
@@ -166,11 +479,15 @@
></div>
{:else}
<!-- svelte-ignore a11y_media_has_caption -->
<!-- For HLS sources the $effect above assigns src (native HLS) or attaches hls.js; the bare element has no src here. -->
<!-- `preload="metadata"` keeps the poster visible until the user clicks play. -->
<!-- `crossorigin` is intentionally omitted: posters live on r2.dev (no CORS), Plyr loads its own `blank.mp4` from cdn.plyr.io (no CORS), and `<track>` works with `blob:` URLs (which the host app fetches via its API client and passes via the `tracks` prop). -->
<video
bind:this={videoElement}
{src}
src={hls ? undefined : src}
{poster}
{playsinline}
preload="metadata"
class="plyr-player ui:h-full ui:w-full ui:object-contain"
>
{#each tracks as track (track.src + track.srclang)}
@@ -185,6 +502,20 @@
</video>
{/if}
{#if playbackFailed && options.playbackErrorLabel}
<div
class="ui:absolute ui:inset-0 ui:z-20 ui:flex ui:flex-col ui:items-center ui:justify-center ui:gap-4 ui:bg-muted/95 ui:px-6 ui:text-center"
role="alert"
>
<p class="ui:text-sm ui:text-muted-foreground">{options.playbackErrorLabel}</p>
{#if options.onPlaybackReload && options.playbackReloadLabel}
<Button variant="outline" disabled={reloadInFlight} onclick={() => void handlePlaybackReloadClick()}>
{options.playbackReloadLabel}
</Button>
{/if}
</div>
{/if}
{#if options.transcriptPanelControl}
<button
bind:this={transcriptButtonElement}
@@ -13,6 +13,12 @@ export interface VideoTextTrack {
export interface VideoSource {
type: VideoType;
url: string;
/**
* When true, `url` is treated as an HLS master playlist (.m3u8). The
* player attaches hls.js (or uses native HLS on Safari) instead of
* setting the URL directly on the `<video>` element.
*/
hls?: boolean;
metadata?: {
svid?: string;
thumbnailUrl?: string;
@@ -36,6 +42,14 @@ export interface MediaPlayerOptions {
onFirstPlay?: () => void;
/** After Plyr is constructed (seek via `player.currentTime = n`). */
onPlayerReady?: (player: Plyr) => void;
/** Shown when the media element fails to load or decode. */
playbackErrorLabel?: string;
/** Label for the manual reload control shown after a playback failure. */
playbackReloadLabel?: string;
/** Fetch a fresh playback URL (or otherwise prepare a retry). Return true to retry loading. */
onPlaybackReload?: () => boolean | Promise<boolean>;
/** Fires after a new `src` finishes loading metadata (used to restore seek position). */
onSourceLoaded?: (video: HTMLVideoElement) => void;
/**
* HTML5 playback only (not YouTube/embed): adds a control-bar button after Plyr inits.
* Use for actions like reopening a transcript side panel without toggling on-video captions.
@@ -44,4 +58,12 @@ export interface MediaPlayerOptions {
label: string;
onClick: () => void;
};
/**
* For HLS sources only. Invoked before hls.js (or native HLS) loads the
* manifest. Use it to mint a short-lived signed cookie that the
* tenant-router Worker validates when serving segments in Cloud
* production. Locally / self-hosted this is typically a no-op or a
* best-effort call that fails benignly.
*/
onBeforeHlsLoad?: () => Promise<void>;
}
@@ -0,0 +1,219 @@
# Org + Course landing pages
This folder holds the entire landing-page system: the public org site (`{theme}.org`) and the per-course landing (`{theme}.course`), in 10 themes.
Both surfaces share the same nav, hero, footer, tokens, and edit context. A course landing inherits its theme from the org's `landingpage.theme` — courses don't pick their own.
## Folder map
```
packages/ui/src/custom/org-landing-page/
│
├── types.ts Public types: OrgLandingPageProps, CourseLandingPageProps,
│ CourseLandingPageLabels, OrgLandingPageTheme, …
├── theme-vars-base.ts Type + baseTokenVars for --landing-* CSS variables
├── theme-style.ts Thin registry: aggregates per-theme vars + exports themeStyle()
├── course-tokens-base.ts Type + baseCourseTokens + tokenize() helper
├── course-landing-page.tokens.ts Thin registry: aggregates per-theme courseTokens + exports
│ courseLandingTokens(theme)
├── course-landing-page.helpers.ts alignHeroCtaWithPricing, buildCourseSectionNavItems
├── fixtures.ts mockOrgLandingPageProps, mockCourseLandingPageProps
├── index.ts Public exports
│
├── edit-context.ts LandingSectionKey + setLandingPageEditContext/getLandingPageEditContext
├── editable-section.svelte Wrapper that makes a section click-to-edit when a context is set
├── landing-button.svelte Theme-aware button used by every CTA
├── landing-page-footer.svelte Shared footer, per-theme class tokens
├── landing-page-footer.tokens.ts
│
├── {theme}/ Everything for one theme (minimal, bold, classic, saas, tech,
│ │ studio, corporate, terminal, editorial, vibrant)
│ ├── index.ts re-exports { nav, hero, courseCard, org, course }
│ ├── nav.svelte reused by both org and course
│ ├── hero.svelte reused; course populates with course-shaped hero data
│ ├── course-card.svelte used by the org catalog
│ ├── org.svelte org-landing composer for this theme
│ ├── course.svelte course-landing composer for this theme
│ ├── vars.ts --landing-* CSS variables for this theme
│ └── course-tokens.ts CourseLandingTokens record for this theme
│
└── course-*.svelte Shared body components driven by `variant` + tokens:
course-section-nav, course-social-proof, course-info-blocks, course-curriculum,
course-chips, course-instructor, course-reviews, course-pricing
```
## A theme is one folder
Every artifact for a theme — composer, sub-components, CSS variables, class tokens — lives in `{theme}/`. To touch one theme you edit one directory. To delete one you `rm -rf {theme}/`. To duplicate one you copy the folder.
The central registries (`theme-style.ts`, `course-landing-page.tokens.ts`, `index.ts`) only import-and-aggregate from each `{theme}/`.
## Three-layer separation
```
┌────────────────────────────────────────┐
│ {theme}/course.svelte (orchestration)│ ← composes the theme
│ ── reuses {theme}/nav, {theme}/hero, │
│ landing-page-footer │
└──┬─────────────────────────────────────┘
│ renders, passes variant="…"
▼
┌────────────────────────────────────────┐
│ course-*.svelte (shared body) │ ← one component per section
│ • course-section-nav │ used by all 10 themes
│ • course-social-proof │
│ • course-info-blocks │
│ • course-curriculum │
│ • course-chips │
│ • course-instructor │
│ • course-reviews │
│ • course-pricing │
└──┬─────────────────────────────────────┘
│ reads courseLandingTokens(variant)
▼
┌────────────────────────────────────────┐
│ {theme}/course-tokens.ts (tokens) │ ← per-theme class strings
│ {theme}/vars.ts (vars) │ and CSS variables
└────────────────────────────────────────┘
```
The composer only orchestrates. Body components only render. Theme variation lives in tokens + CSS vars.
## Token contract
Each theme owns its `course-tokens.ts` exporting `courseTokens: CourseLandingTokens` via `tokenize({...overrides})`. The base lives in `course-tokens-base.ts`. Every body component reads `t = courseLandingTokens(variant)` and applies `{t.sectionShell}`, `{t.heading}`, `{t.curriculumModule}`, … Anything not overridden falls through to the base.
**Tokens-first rule.** Body components must not hardcode theme-varying utilities (`font-bold`, `tracking-tight`, `rounded-2xl`, `bg-card-soft`, …). Anything that differs between themes lives in tokens. Layout-only utilities that are identical everywhere (`flex`, `grid`, `gap-6`, `py-12`) can sit inline.
**Off-palette rule.** A course body section must only use surfaces that exist in the theme's org template. Don't introduce `bg-section` greys to minimal, don't paint card-soft on bold, etc.
## Data flow
The dashboard's `<CourseLandingPage>` (`apps/dashboard/src/lib/features/ui/course-landing-page/course-landing-page.svelte`) is the entry point. It:
1. Reads the org's chosen theme from `landingpage.theme`.
2. Maps `course + org` → `CourseLandingPageProps` via `buildCourseLandingPageProps()`. Section visibility (`metadata.sectionDisplay`) is honoured here — hidden sections are stripped from `info` / `reviews` / `instructor` before rendering.
3. Lazy-loads the theme bundle via `importThemeBundle(theme)` — one bundle per theme; the other nine never download. The bundle exposes `{ nav, hero, courseCard, org, course }`.
The composer wires nav/hero/footer (theme-specific files) around the shared body components, threading `variant={theme}` and the same `labels` prop into each.
## Edit context (click-to-edit)
`EditableLandingSection sectionKey="…"` wraps every editable region. When a parent route calls `setLandingPageEditContext({ selectedKey, selectKey, labelFor, iconFor })`, clicking a section in the preview calls `selectKey(key)`. The Editor sidebar reads the same key from a bound prop and opens the matching form. Without a context, the wrapper is a transparent pass-through.
Course `LandingSectionKey` values: `header`, `requirement`, `description`, `goals`, `certificate`, `curriculum`, `chips`, `instructor`, `reviews`, `pricing`.
---
## Adding a new theme
A theme is one folder. The central registries are the only files outside that folder you touch.
### 1. Declare the theme key
`types.ts`:
```ts
export type OrgLandingPageTheme =
| 'minimal'
| …
| 'newtheme';
```
### 2. Create the folder
```
newtheme/
├── index.ts
├── nav.svelte
├── hero.svelte
├── course-card.svelte
├── org.svelte
├── course.svelte
├── vars.ts
└── course-tokens.ts
```
Quickest path: `cp -R minimal/ newtheme/` and edit.
### 3. Fill in each file
- `nav.svelte`, `hero.svelte`, `course-card.svelte` — the theme's UI personality. Hero composes `{@render navigation()}` if the theme renders nav inside the hero.
- `org.svelte` — org landing composer for this theme.
- `course.svelte` — course landing composer (use any existing theme's `course.svelte` as a template; the body sequence is the same).
- `vars.ts` — exports `vars: LandingThemeVars`. Spread `...baseTokenVars` and override the differences. Use raw hex only for genuinely off-palette themes (see `terminal/vars.ts`, `editorial/vars.ts`).
- `course-tokens.ts` — exports `courseTokens: CourseLandingTokens = tokenize({ ... })`. Only override regions that differ from the base.
- `index.ts`:
```ts
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
```
### 4. Wire into the central registries
`theme-style.ts`:
```ts
import { vars as newthemeVars } from './newtheme/vars';
…
export const LANDING_THEME_VARS = {
…,
newtheme: newthemeVars
};
```
`course-landing-page.tokens.ts`:
```ts
import { courseTokens as newthemeTokens } from './newtheme/course-tokens';
…
const TOKENS = {
…,
newtheme: newthemeTokens
};
```
`index.ts`:
```ts
import * as newtheme from './newtheme';
export const NewthemeLandingPage = newtheme.org;
export const NewthemeCourseLanding = newtheme.course;
export const NewthemeLandingNav = newtheme.nav;
export const NewthemeLandingHero = newtheme.hero;
export const NewthemeLandingCourseCard = newtheme.courseCard;
```
### 5. Register in the dashboard utils
`apps/dashboard/src/lib/features/org/utils/landing-page.ts`:
- Add the key to `landingPageThemes`.
- Add a `case 'newtheme'` to `importThemeBundle` (this is the only lazy-load switch — `importThemeComponent`, `importThemeNavHero`, `importThemeCourseCard`, and `importCourseLandingPageTheme` are thin adapters over it).
- If your hero composes `{@render navigation()}`, add the theme key to `NAV_SNIPPET_THEMES`.
- Add an entry to `themeCourseGridClass`.
### 6. Pick body layouts
- `course-curriculum.svelte` has a `layoutByTheme` map (`accordion` for collapsible, `chapters` for permanently expanded). Add `newtheme`.
- If the info-blocks should use a different layout, do the same in `course-info-blocks.svelte`.
### 7. Storybook
In `packages/storybook/src/templates/org-landing-page/`:
- `org-landing-page.stories.svelte` — add `<NewthemeLandingPage {...mockProps} />`.
- `course-landing-page.stories.svelte` — add `<NewthemeCourseLanding {...mockProps} />`.
### 8. Audit
- Walk the new story top-to-bottom and compare against the org story for the same theme. Surfaces should match.
- `grep -nE 'font-(black|extrabold|bold|semibold|medium)|tracking-(tight|tighter|wider|widest)|rounded-(none|sm|md|lg|xl|2xl|3xl|full)' course-*.svelte` — body components should return no matches.
- `grep -nE 'bg-\[var\(--landing-(bg-section|card-soft|card)\)' newtheme/course-tokens.ts` — confirm you only used surfaces present in the org theme.
- `pnpm exec svelte-check --threshold error` from the repo root.
That's it — one folder, two registry hooks, two Storybook entries.
@@ -25,7 +25,7 @@
</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
class="ui:group ui:@container ui:relative ui:overflow-hidden ui:bg-[var(--landing-bg)] ui:rounded-3xl ui:p-4 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'}"
>
@@ -35,65 +35,67 @@
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:h-full ui:flex-col ui:@lg:flex-row ui:gap-6">
{#if course.logo}
<img
src={course.logo}
alt={course.title}
class="ui:w-full ui:@lg:w-48 ui:@lg:shrink-0 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: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>
<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>
</div>
</Card.Root>
@@ -0,0 +1,42 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
heading:
'ui:text-4xl ui:md:text-6xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofShell: 'ui:py-10 ui:px-4 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]',
socialProofValue: 'ui:text-4xl ui:text-[var(--landing-bg)] ui:[font-weight:var(--landing-heading-weight)]',
socialProofLabel: 'ui:text-xs ui:text-[var(--landing-bg)]/70 ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
socialProofDivider: 'ui:size-2 ui:rounded-full ui:bg-[var(--landing-accent)]',
infoBlock:
'ui:flex ui:flex-col ui:gap-3 ui:p-8 ui:border ui:border-[var(--landing-border)]/50' +
' ui:[border-radius:var(--landing-radius-card)]',
infoBlockHeading:
'ui:text-3xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumModule:
'ui:mb-3 ui:p-6 ui:border ui:border-[var(--landing-border)]/50' + ' ui:[border-radius:var(--landing-radius-card)]',
curriculumModuleTitle:
'ui:text-xl ui:md:text-2xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumModuleMeta:
'ui:text-xs ui:font-medium ui:px-3 ui:py-1 ui:bg-[var(--landing-accent)] ui:text-[var(--landing-accent-fg)]' +
' ui:[border-radius:var(--landing-radius-pill)]',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start ui:p-8' +
' ui:border ui:border-[var(--landing-border)]/50 ui:[border-radius:var(--landing-radius-card)]',
instructorAvatar: 'ui:size-28 ui:rounded-full ui:object-cover ui:ring-4 ui:ring-[var(--landing-accent)]',
instructorName:
'ui:text-3xl ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
reviewCard: 'ui:p-6 ui:border ui:border-[var(--landing-border)]/50 ui:[border-radius:var(--landing-radius-card)]',
pricingShell:
'ui:p-8 ui:bg-[var(--landing-fg)] ui:text-[var(--landing-bg)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:[box-shadow:var(--landing-shadow-card)]',
pricingAmount:
'ui:text-5xl ui:md:text-6xl ui:text-[var(--landing-bg)]' +
' ui:[font-weight:var(--landing-heading-weight)] ui:[letter-spacing:var(--landing-heading-tracking)]',
pricingCurrency: 'ui:text-base ui:text-[var(--landing-bg)]/70 ui:ml-1',
pricingDiscount: 'ui:text-base ui:text-[var(--landing-bg)]/60 ui:line-through',
pricingEyebrow: 'ui:text-xs ui:text-[var(--landing-bg)]/70 ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
pricingFeature: 'ui:flex ui:items-center ui:gap-2 ui:text-sm ui:text-[var(--landing-bg)]/90'
});
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import BoldNav from './nav.svelte';
import BoldHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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={heroProps} />
<CourseSectionNav variant="bold" items={sectionNavItems} {labels} />
<CourseSocialProof variant="bold" {socialProof} {labels} />
<CourseInfoBlocks variant="bold" {info} {labels} />
<CourseCurriculum variant="bold" {curriculum} {labels} />
{#if chips}
<CourseChips variant="bold" {chips} {labels} />
{/if}
<CourseInstructor variant="bold" {instructor} {labels} />
<CourseReviews variant="bold" {reviews} {labels} />
<CoursePricing variant="bold" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="bold" />
</div>
@@ -6,9 +6,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
showActions?: boolean;
}
let { hero }: Props = $props();
let { hero, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -16,25 +17,29 @@
<div>
<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>
{#if showActions}
<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>
{/if}
</div>
{#if hero.image}
<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" />
<div class="ui:relative">
<div
class="ui:absolute ui:inset-0 ui:bg-[var(--landing-accent)]/5 ui:rounded-3xl 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" />
</div>
{/if}
</section>
</EditableLandingSection>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@2xl:grid-cols-2 ui:gap-6';
@@ -1,15 +1,15 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import BoldNav from './bold/nav.svelte';
import BoldHero from './bold/hero.svelte';
import BoldCourseCard from './bold/course-card.svelte';
import { Button } from '../../base/button';
import { DotPattern } from '../animation/dot-pattern';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import BoldNav from './nav.svelte';
import BoldHero from './hero.svelte';
import BoldCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { DotPattern } from '../../animation/dot-pattern';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '900',
'--landing-heading-tracking': '-0.02em',
'--landing-radius-card': '24px',
'--landing-shadow-card': '0 8px 24px -20px color-mix(in oklab, var(--landing-fg), transparent 80%)',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -0,0 +1,25 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionHeader: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:mb-10',
heading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)] ui:text-center' +
' ui:[font-weight:var(--landing-heading-weight)]',
body: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:text-center',
headingRule: 'ui:h-px ui:w-16 ui:bg-[var(--landing-accent)] ui:mx-auto ui:mt-2',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)] ui:text-center',
socialProofItem: 'ui:flex ui:flex-col ui:items-center ui:gap-1 ui:text-[var(--landing-fg)]',
socialProofDivider: 'ui:w-px ui:h-10 ui:bg-[var(--landing-border)]',
infoGrid: 'ui:flex ui:flex-col ui:items-center ui:gap-16 ui:max-w-3xl ui:mx-auto',
infoBlock: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:w-full',
infoBlockBody: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:text-center',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:gap-6 ui:justify-items-center',
reviewCard:
'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:text-center',
instructorShell: 'ui:flex ui:flex-col ui:items-center ui:text-center ui:gap-3 ui:max-w-2xl ui:mx-auto',
instructorAvatar: 'ui:size-28 ui:rounded-full ui:object-cover ui:border-2 ui:border-[var(--landing-accent)]',
pricingShell:
'ui:p-8 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:text-center'
});
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import ClassicNav from './nav.svelte';
import ClassicHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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>
<ClassicHero hero={heroProps} />
<CourseSectionNav variant="classic" items={sectionNavItems} {labels} />
<CourseSocialProof variant="classic" {socialProof} {labels} />
<CourseInfoBlocks variant="classic" {info} {labels} />
<CourseCurriculum variant="classic" {curriculum} {labels} />
{#if chips}
<CourseChips variant="classic" {chips} {labels} />
{/if}
<CourseInstructor variant="classic" {instructor} {labels} />
<CourseReviews variant="classic" {reviews} {labels} />
<CoursePricing variant="classic" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="classic" />
</div>
@@ -8,9 +8,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
showActions?: boolean;
}
let { hero }: Props = $props();
let { hero, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -30,23 +31,25 @@
{hero.heading}
</BlurIn>
<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>
{#if showActions}
<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>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-6';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import ClassicNav from './classic/nav.svelte';
import ClassicHero from './classic/hero.svelte';
import ClassicCourseCard from './classic/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import ClassicNav from './nav.svelte';
import ClassicHero from './hero.svelte';
import ClassicCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '700',
'--landing-eyebrow-tracking': '0.08em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '4px',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -0,0 +1,24 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
heading: 'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofLabel:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
infoGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
infoBlock: 'ui:flex ui:flex-col ui:gap-3 ui:p-8 ui:border-r ui:border-b ui:border-[var(--landing-border)]',
curriculumContainer: 'ui:border-t ui:border-[var(--landing-border)]',
curriculumModule: 'ui:border-b ui:border-[var(--landing-border)] ui:py-0',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:py-5 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
reviewCard: 'ui:p-6 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]',
instructorShell:
'ui:grid ui:grid-cols-1 ui:md:grid-cols-[auto_1fr] ui:gap-0 ui:border ui:border-[var(--landing-border)]',
instructorAvatarWrap:
'ui:border-b ui:md:border-b-0 ui:md:border-r ui:border-[var(--landing-border)] ui:p-6 ui:flex ui:items-center ui:justify-center',
pricingShell: 'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import CorporateNav from './nav.svelte';
import CorporateHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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={heroProps}>
{#snippet navigation()}
<CorporateNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</CorporateHero>
<CourseSectionNav variant="corporate" items={sectionNavItems} {labels} />
<CourseSocialProof variant="corporate" {socialProof} {labels} />
<CourseInfoBlocks variant="corporate" {info} {labels} />
<CourseCurriculum variant="corporate" {curriculum} {labels} />
{#if chips}
<CourseChips variant="corporate" {chips} {labels} />
{/if}
<CourseInstructor variant="corporate" {instructor} {labels} />
<CourseReviews variant="corporate" {reviews} {labels} />
<CoursePricing variant="corporate" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="corporate" />
</div>
@@ -9,9 +9,10 @@
orgName: OrgLandingPageProps['orgName'];
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
showActions?: boolean;
}
let { orgName, hero, navigation }: Props = $props();
let { orgName, hero, navigation, showActions = true }: Props = $props();
const stats = $derived(hero.stats ?? []);
</script>
@@ -47,22 +48,24 @@
{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.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="corporate"
/>
{/if}
</div>
{#if showActions}
<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>
{/if}
</div>
{#if stats.length > 0}
@@ -0,0 +1,8 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass =
'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import CorporateNav from './corporate/nav.svelte';
import CorporateHero from './corporate/hero.svelte';
import CorporateCourseCard from './corporate/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CorporateNav from './nav.svelte';
import CorporateHero from './hero.svelte';
import CorporateCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,11 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '600',
'--landing-eyebrow-tracking': '0.12em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '0px',
'--landing-radius-pill': '0px',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -0,0 +1,82 @@
<script lang="ts">
import type { CourseChipSections, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
interface Props {
variant: OrgLandingPageTheme;
chips: CourseChipSections;
labels?: CourseLandingPageLabels;
/** How many chips to show before the "Show all" affordance kicks in. */
initialLimit?: number;
}
let { variant, chips, labels, initialLimit = 9 }: Props = $props();
const t = $derived(courseLandingTokens(variant));
const skills = $derived(chips.skills ?? []);
const tools = $derived(chips.tools ?? []);
const hasSkills = $derived(skills.length > 0);
const hasTools = $derived(tools.length > 0);
const hasAny = $derived(hasSkills || hasTools);
let showAllSkills = $state(false);
let showAllTools = $state(false);
const visibleSkills = $derived(showAllSkills ? skills : skills.slice(0, initialLimit));
const visibleTools = $derived(showAllTools ? tools : tools.slice(0, initialLimit));
const skillsOverflow = $derived(skills.length > initialLimit);
const toolsOverflow = $derived(tools.length > initialLimit);
const showAllLabel = $derived(labels?.chipsShowAllLabel ?? 'Show all');
const showLessLabel = $derived(labels?.chipsShowLessLabel ?? 'Show less');
</script>
{#if hasAny}
<EditableLandingSection sectionKey="chips">
<section id="course-chips" class={t.sectionShell}>
<div class={t.sectionInner}>
<div class={t.chipsSectionShell}>
{#if hasSkills}
<div>
<h2 class={t.chipsGroupHeading}>
{labels?.chipsSkillsHeading ?? "Skills you'll gain"}
</h2>
<div class={t.chipsGroup}>
{#each visibleSkills as skill (skill)}
<span class={t.chip}>{skill}</span>
{/each}
{#if skillsOverflow}
<button type="button" class={t.chipsShowAll} onclick={() => (showAllSkills = !showAllSkills)}>
{showAllSkills ? showLessLabel : showAllLabel}
</button>
{/if}
</div>
</div>
{/if}
{#if hasTools}
<div>
<h2 class={t.chipsGroupHeading}>
{labels?.chipsToolsHeading ?? "Tools you'll learn"}
</h2>
<div class={t.chipsGroup}>
{#each visibleTools as tool (tool)}
<span class={t.chip}>{tool}</span>
{/each}
{#if toolsOverflow}
<button type="button" class={t.chipsShowAll} onclick={() => (showAllTools = !showAllTools)}>
{showAllTools ? showLessLabel : showAllLabel}
</button>
{/if}
</div>
</div>
{/if}
</div>
</div>
</section>
</EditableLandingSection>
{/if}
@@ -0,0 +1,159 @@
<script lang="ts">
import type { CourseCurriculum, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
import ChevronUpIcon from '@lucide/svelte/icons/chevron-up';
import CircleCheckIcon from '@lucide/svelte/icons/circle-check';
interface Props {
variant: OrgLandingPageTheme;
curriculum: CourseCurriculum;
labels?: CourseLandingPageLabels;
}
let { variant, curriculum, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
type CurriculumLayout = 'accordion' | 'chapters';
const layoutByTheme: Record<OrgLandingPageTheme, CurriculumLayout> = {
minimal: 'accordion',
studio: 'accordion',
bold: 'chapters',
classic: 'chapters',
saas: 'chapters',
tech: 'chapters',
corporate: 'chapters',
terminal: 'chapters',
editorial: 'chapters',
vibrant: 'chapters'
};
const layout = $derived<CurriculumLayout>(layoutByTheme[variant] ?? 'accordion');
function lecturePrefix(index: number): string {
const fn = labels?.lecturePrefix;
if (fn) return fn(index);
return String(index + 1).padStart(2, '0');
}
function lessonsLabel(count: number): string {
return labels?.lessonsLabel?.(count) ?? `${count} ${count === 1 ? 'lesson' : 'lessons'}`;
}
function exercisesLabel(count: number): string {
return labels?.exercisesLabel?.(count) ?? `${count} ${count === 1 ? 'exercise' : 'exercises'}`;
}
// Accordion state — first section open by default.
let expanded = $state<Record<string, boolean>>({});
$effect(() => {
const initial: Record<string, boolean> = {};
curriculum.sections.forEach((section, idx) => {
if (!(section.id in expanded)) initial[section.id] = idx === 0;
});
if (Object.keys(initial).length > 0) expanded = { ...expanded, ...initial };
});
function toggle(id: string) {
expanded = { ...expanded, [id]: !expanded[id] };
}
</script>
<EditableLandingSection sectionKey="curriculum">
<section id="curriculum" class={t.sectionShell}>
<div class={t.sectionInner}>
<div class={t.sectionHeader}>
<span class={t.eyebrow}>{labels?.curriculumEyebrow ?? 'Curriculum'}</span>
<h2 class={t.heading}>{labels?.curriculumHeading ?? 'What you will study'}</h2>
<span class={t.headingRule} aria-hidden="true"></span>
</div>
{#if layout === 'accordion'}
<div class={t.curriculumContainer}>
{#each curriculum.sections as section (section.id)}
<div class={t.curriculumModule}>
<button
type="button"
class={t.curriculumModuleHeader}
onclick={() => toggle(section.id)}
aria-expanded={expanded[section.id] ?? false}
>
<span class="ui:flex ui:items-center ui:gap-3">
{#if expanded[section.id]}
<ChevronUpIcon class="ui:size-4 ui:text-[var(--landing-fg-muted)]" />
{:else}
<ChevronDownIcon class="ui:size-4 ui:text-[var(--landing-fg-muted)]" />
{/if}
<span class={t.curriculumModuleTitle}>{section.title}</span>
</span>
<span class={t.curriculumModuleMeta}>
{lessonsLabel(section.lessons.length)}{section.exerciseCount
? ` · ${exercisesLabel(section.exerciseCount)}`
: ''}
</span>
</button>
{#if expanded[section.id]}
<div>
{#each section.lessons as lesson, lessonIdx (lesson.id)}
<div class={t.curriculumLessonRow}>
<span class={t.curriculumLessonPrefix}>{lecturePrefix(lessonIdx)}</span>
<CircleCheckIcon class={`${t.curriculumLessonIcon} ui:size-4`} />
<span class={t.curriculumLessonTitle}>{lesson.title}</span>
{#if lesson.durationMinutes}
<span class="ui:ml-auto ui:text-xs ui:text-[var(--landing-fg-muted)]">
{lesson.durationMinutes} min
</span>
{/if}
</div>
{/each}
</div>
{/if}
</div>
{/each}
</div>
{:else}
<!-- chapters: every section permanently expanded as its own block -->
<div class="ui:flex ui:flex-col">
{#each curriculum.sections as section, sectionIdx (section.id)}
<div class={t.curriculumModule}>
<div
class="ui:flex ui:flex-col ui:items-start ui:gap-2 ui:mb-4 ui:md:flex-row ui:md:items-baseline ui:md:justify-between ui:md:gap-4"
>
<div class="ui:flex ui:items-baseline ui:gap-4">
<span
class="ui:text-[var(--landing-accent)] ui:[font-weight:var(--landing-heading-weight)] ui:text-2xl ui:md:text-3xl"
>
{String(sectionIdx + 1).padStart(2, '0')}
</span>
<h3 class={t.curriculumModuleTitle}>{section.title}</h3>
</div>
<span class={t.curriculumModuleMeta}>
{lessonsLabel(section.lessons.length)}{section.exerciseCount
? ` · ${exercisesLabel(section.exerciseCount)}`
: ''}
</span>
</div>
<div class="ui:flex ui:flex-col">
{#each section.lessons as lesson, lessonIdx (lesson.id)}
<div class={t.curriculumLessonRow}>
<span class={t.curriculumLessonPrefix}>{lecturePrefix(lessonIdx)}</span>
<CircleCheckIcon class={`${t.curriculumLessonIcon} ui:size-4`} />
<span class={t.curriculumLessonTitle}>{lesson.title}</span>
{#if lesson.durationMinutes}
<span class="ui:ml-auto ui:text-xs ui:text-[var(--landing-fg-muted)]">
{lesson.durationMinutes} min
</span>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,214 @@
<script lang="ts">
import type { CourseInfoBlocks, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
import AwardIcon from '@lucide/svelte/icons/award';
interface Props {
variant: OrgLandingPageTheme;
info: CourseInfoBlocks;
labels?: CourseLandingPageLabels;
}
let { variant, info, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
type InfoLayout = 'prose-grid' | 'cards' | 'mono-blocks' | 'editorial' | 'oversized';
const layoutByTheme: Record<OrgLandingPageTheme, InfoLayout> = {
minimal: 'prose-grid',
bold: 'cards',
classic: 'prose-grid',
saas: 'prose-grid',
tech: 'mono-blocks',
studio: 'cards',
corporate: 'prose-grid',
terminal: 'mono-blocks',
editorial: 'editorial',
vibrant: 'oversized'
};
const layout = $derived<InfoLayout>(layoutByTheme[variant] ?? 'prose-grid');
type BlockKey = 'requirement' | 'description' | 'goals';
type Block = { id: BlockKey; eyebrow: string; heading: string; html: string; monoLabel: string };
const blocks = $derived(
[
info.requirements
? {
id: 'requirement' as const,
eyebrow: labels?.infoRequirementsEyebrow ?? 'Before you start',
heading: labels?.infoRequirementsHeading ?? 'Requirements',
html: info.requirements,
monoLabel: '01_requirements.md'
}
: null,
info.description
? {
id: 'description' as const,
eyebrow: labels?.infoDescriptionEyebrow ?? 'About this course',
heading: labels?.infoDescriptionHeading ?? 'What you will learn',
html: info.description,
monoLabel: '02_about.md'
}
: null,
info.goals
? {
id: 'goals' as const,
eyebrow: labels?.infoGoalsEyebrow ?? 'Outcomes',
heading: labels?.infoGoalsHeading ?? 'Goals',
html: info.goals,
monoLabel: '03_goals.md'
}
: null
].filter(Boolean) as Block[]
);
const certCaption = 'Awarded on completion. Shareable on LinkedIn with a verification link auditors can follow.';
</script>
{#if blocks.length > 0 || info.certificateUrl}
<section id="course-info" class={t.sectionShell}>
<div class={t.sectionInner}>
{#if layout === 'prose-grid'}
<div class={t.infoGrid}>
{#each blocks as block (block.id)}
<EditableLandingSection sectionKey={block.id}>
<article id={block.id} class={t.infoBlock}>
<span class={t.infoBlockEyebrow}>{block.eyebrow}</span>
<h2 class={t.infoBlockHeading}>{block.heading}</h2>
<div class={t.infoBlockBody}>{@html block.html}</div>
</article>
</EditableLandingSection>
{/each}
</div>
{:else if layout === 'cards'}
<div class="ui:grid ui:grid-cols-1 ui:gap-6">
{#each blocks as block (block.id)}
<EditableLandingSection sectionKey={block.id}>
<article id={block.id} class={t.infoBlock}>
<span class={t.infoBlockEyebrow}>{block.eyebrow}</span>
<h2 class={t.infoBlockHeading}>{block.heading}</h2>
<div class={t.infoBlockBody}>{@html block.html}</div>
</article>
</EditableLandingSection>
{/each}
</div>
{:else if layout === 'mono-blocks'}
<div class="ui:flex ui:flex-col ui:gap-6">
{#each blocks as block (block.id)}
<EditableLandingSection sectionKey={block.id}>
<article
id={block.id}
class={`ui:border ui:border-[var(--landing-border)] ${
variant === 'terminal' ? 'ui:bg-[var(--landing-card)]' : ''
}`}
>
<div
class={`ui:flex ui:items-center ui:justify-between ui:px-4 ui:py-2 ui:border-b ui:border-[var(--landing-border)] ${
variant === 'terminal' ? 'ui:bg-[var(--landing-bg-section)]' : ''
}`}
>
<span class="ui:text-xs ui:font-mono ui:text-[var(--landing-fg-muted)]">
// {block.monoLabel}
</span>
<span class={t.infoBlockEyebrow}>{block.eyebrow}</span>
</div>
<div class="ui:p-6">
<h2 class={`${t.infoBlockHeading} ui:mb-3`}>{block.heading}</h2>
<div class={t.infoBlockBody}>{@html block.html}</div>
</div>
</article>
</EditableLandingSection>
{/each}
</div>
{:else if layout === 'editorial'}
<div class="ui:flex ui:flex-col ui:gap-16">
{#each blocks as block, idx (block.id)}
<EditableLandingSection sectionKey={block.id}>
<article
id={block.id}
class="ui:grid ui:grid-cols-1 ui:md:grid-cols-[180px_1fr] ui:gap-8 ui:items-start ui:pt-10 ui:border-t ui:border-[var(--landing-border)]"
>
<div class="ui:flex ui:flex-col ui:gap-1">
<span class={t.infoBlockEyebrow}>{block.eyebrow}</span>
<span
class="ui:text-3xl ui:text-[var(--landing-accent)] ui:[font-weight:var(--landing-heading-weight)]"
>
{String(idx + 1).padStart(2, '0')}
</span>
</div>
<div>
<h2 class={`${t.infoBlockHeading} ui:mb-4`}>{block.heading}</h2>
<div class={t.infoBlockBody}>{@html block.html}</div>
</div>
</article>
</EditableLandingSection>
{/each}
</div>
{:else if layout === 'oversized'}
<div class="ui:flex ui:flex-col ui:gap-20">
{#each blocks as block (block.id)}
<EditableLandingSection sectionKey={block.id}>
<article id={block.id} class="ui:grid ui:grid-cols-1 ui:md:grid-cols-[1fr_2fr] ui:gap-10">
<div>
<span class={t.infoBlockEyebrow}>{block.eyebrow}</span>
<h2 class={`${t.infoBlockHeading} ui:mt-2`}>{block.heading}</h2>
</div>
<div class={t.infoBlockBody}>{@html block.html}</div>
</article>
</EditableLandingSection>
{/each}
</div>
{/if}
{#if info.certificateUrl}
<EditableLandingSection sectionKey="certificate">
<div id="certificate" class="ui:mt-16">
<div class={t.sectionHeader}>
<span class={t.eyebrow}>{labels?.infoCertificateEyebrow ?? 'On completion'}</span>
<h2 class={t.heading}>{labels?.infoCertificateHeading ?? 'Your certificate'}</h2>
<span class={t.headingRule} aria-hidden="true"></span>
</div>
<div
class={`${t.certificateShell} ui:grid ui:grid-cols-1 ui:md:grid-cols-[260px_1fr] ui:gap-8 ui:items-center`}
>
<div class={t.certificateFrame}>
<img
src={info.certificateUrl}
alt="Certificate template preview"
class="ui:w-full ui:h-auto ui:block"
loading="lazy"
/>
</div>
<div class="ui:flex ui:flex-col ui:gap-3">
<div class="ui:flex ui:items-center ui:gap-2 ui:text-[var(--landing-accent)]">
<AwardIcon class="ui:size-5" />
<span class={t.infoBlockEyebrow}>Verifiable credential</span>
</div>
<p class={t.infoBlockBody}>{certCaption}</p>
<ul class="ui:flex ui:flex-col ui:gap-1.5 ui:text-sm ui:text-[var(--landing-fg)]">
<li class="ui:flex ui:items-center ui:gap-2">
<span class="ui:size-1 ui:[border-radius:9999px] ui:bg-[var(--landing-accent)]"></span>
Issued the day you finish the capstone
</li>
<li class="ui:flex ui:items-center ui:gap-2">
<span class="ui:size-1 ui:[border-radius:9999px] ui:bg-[var(--landing-accent)]"></span>
Shareable on LinkedIn with one click
</li>
<li class="ui:flex ui:items-center ui:gap-2">
<span class="ui:size-1 ui:[border-radius:9999px] ui:bg-[var(--landing-accent)]"></span>
Carries a verification URL for auditors
</li>
</ul>
</div>
</div>
</div>
</EditableLandingSection>
{/if}
</div>
</section>
{/if}
@@ -0,0 +1,62 @@
<script lang="ts">
import type { CourseInstructor, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
interface Props {
variant: OrgLandingPageTheme;
instructor: CourseInstructor;
labels?: CourseLandingPageLabels;
}
let { variant, instructor, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
const fallbackAvatar = 'https://ui-avatars.com/api/?name=' + encodeURIComponent(instructor.name);
const coursesLabel = $derived(
instructor.coursesNo
? (labels?.instructorCoursesLabel?.(instructor.coursesNo) ?? `${instructor.coursesNo} courses on the platform`)
: null
);
const hasName = $derived(Boolean(instructor.name?.trim()));
</script>
{#if hasName}
<EditableLandingSection sectionKey="instructor">
<section id="instructor" class={t.sectionShell}>
<div class={t.sectionInner}>
<div class={t.sectionHeader}>
<span class={t.eyebrow}>{labels?.instructorEyebrow ?? 'Your instructor'}</span>
<h2 class={t.heading}>{labels?.instructorHeading ?? 'Taught by a practitioner'}</h2>
<span class={t.headingRule} aria-hidden="true"></span>
</div>
<div class={t.instructorShell}>
<div class={t.instructorAvatarWrap}>
<img
src={instructor.imgUrl ?? fallbackAvatar}
alt={instructor.name}
class={t.instructorAvatar}
loading="lazy"
/>
</div>
<div class={t.instructorBodyWrap}>
<h3 class={t.instructorName}>{instructor.name}</h3>
{#if instructor.role}
<p class={t.instructorRole}>{instructor.role}</p>
{/if}
{#if coursesLabel}
<p class={t.instructorMeta}>{coursesLabel}</p>
{/if}
{#if instructor.description}
<p class={t.instructorBody}>{instructor.description}</p>
{/if}
</div>
</div>
</div>
</section>
</EditableLandingSection>
{/if}
@@ -0,0 +1,52 @@
import type { CoursePricing, CourseLandingPageProps, CourseLandingPageLabels } from './types';
export function alignHeroCtaWithPricing(
hero: CourseLandingPageProps['hero'],
pricing: CoursePricing
): CourseLandingPageProps['hero'] {
return {
...hero,
primaryAction: {
label: pricing.ctaLabel,
href: pricing.ctaHref ?? '#pricing',
disabled: hero.primaryAction.disabled
}
};
}
export interface SectionNavItem {
id: string;
label: string;
}
export function buildCourseSectionNavItems(
props: Pick<CourseLandingPageProps, 'info' | 'curriculum' | 'chips' | 'instructor' | 'reviews'>,
labels?: CourseLandingPageLabels
): SectionNavItem[] {
const items: SectionNavItem[] = [];
const { info, curriculum, chips, reviews } = props;
const hasInfo = Boolean(info.requirements || info.description || info.goals || info.certificateUrl);
if (hasInfo) {
items.push({ id: 'course-info', label: labels?.navAboutLabel ?? 'About' });
}
if (curriculum.sections.length > 0) {
items.push({ id: 'curriculum', label: labels?.navCurriculumLabel ?? 'Modules' });
}
const hasChips = Boolean((chips?.skills?.length ?? 0) > 0 || (chips?.tools?.length ?? 0) > 0);
if (hasChips) {
items.push({ id: 'course-chips', label: labels?.navSkillsLabel ?? 'Skills' });
}
items.push({ id: 'instructor', label: labels?.navInstructorLabel ?? 'Instructor' });
if (reviews.items.length > 0) {
items.push({ id: 'reviews', label: labels?.navReviewsLabel ?? 'Reviews' });
}
items.push({ id: 'pricing', label: labels?.navPricingLabel ?? 'Pricing' });
return items;
}
@@ -0,0 +1,31 @@
import type { OrgLandingPageTheme } from './types';
import type { CourseLandingTokens } from './course-tokens-base';
import { courseTokens as minimalTokens } from './minimal/course-tokens';
import { courseTokens as boldTokens } from './bold/course-tokens';
import { courseTokens as classicTokens } from './classic/course-tokens';
import { courseTokens as saasTokens } from './saas/course-tokens';
import { courseTokens as techTokens } from './tech/course-tokens';
import { courseTokens as studioTokens } from './studio/course-tokens';
import { courseTokens as corporateTokens } from './corporate/course-tokens';
import { courseTokens as terminalTokens } from './terminal/course-tokens';
import { courseTokens as editorialTokens } from './editorial/course-tokens';
import { courseTokens as vibrantTokens } from './vibrant/course-tokens';
export type { CourseLandingTokens } from './course-tokens-base';
const TOKENS: Record<OrgLandingPageTheme, CourseLandingTokens> = {
minimal: minimalTokens,
bold: boldTokens,
classic: classicTokens,
saas: saasTokens,
tech: techTokens,
studio: studioTokens,
corporate: corporateTokens,
terminal: terminalTokens,
editorial: editorialTokens,
vibrant: vibrantTokens
};
export function courseLandingTokens(theme: OrgLandingPageTheme): CourseLandingTokens {
return TOKENS[theme] ?? minimalTokens;
}
@@ -0,0 +1,89 @@
<script lang="ts">
import type { CoursePricing, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
import LandingButton from './landing-button.svelte';
import CheckIcon from '@lucide/svelte/icons/check';
import GiftIcon from '@lucide/svelte/icons/gift';
interface Props {
variant: OrgLandingPageTheme;
pricing: CoursePricing;
labels?: CourseLandingPageLabels;
}
let { variant, pricing, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
const isFree = $derived(!pricing.cost || pricing.cost <= 0);
const currencySymbol = $derived(
pricing.currency === 'USD' ? '$' : pricing.currency === 'EUR' ? '€' : pricing.currency === 'GBP' ? '£' : ''
);
const discountedAmount = $derived(
pricing.showDiscount && pricing.discount && pricing.cost
? pricing.cost - (pricing.cost * pricing.discount) / 100
: null
);
const displayAmount = $derived(discountedAmount ?? pricing.cost ?? 0);
</script>
<EditableLandingSection sectionKey="pricing">
<section id="pricing" class={t.sectionShell}>
<div class={`${t.sectionInner} ui:flex ui:justify-center`}>
<div class={`${t.pricingShell} ui:w-full ui:max-w-md`}>
<div class={t.pricingHeader}>
<span class={t.pricingEyebrow}>{labels?.pricingEyebrow ?? 'Enrol'}</span>
{#if isFree}
<span class={t.pricingAmount}>{labels?.freeLabel ?? 'Free'}</span>
{:else}
<div class="ui:flex ui:items-baseline ui:gap-3">
<span class={t.pricingAmount}>
{currencySymbol}{displayAmount.toFixed(0)}
<span class={t.pricingCurrency}>{currencySymbol ? '' : pricing.currency}</span>
</span>
{#if discountedAmount !== null}
<span class={t.pricingDiscount}>
{currencySymbol}{(pricing.cost ?? 0).toFixed(0)}
</span>
{/if}
</div>
{#if pricing.discount && pricing.showDiscount}
<span class={t.pricingSavingsBadge}>
Save {pricing.discount}%
</span>
{/if}
{/if}
</div>
{#if pricing.features && pricing.features.length > 0}
<ul class={t.pricingFeatures}>
{#each pricing.features as feature}
<li class={t.pricingFeature}>
<CheckIcon class={`${t.pricingFeatureIcon} ui:size-4`} />
<span>{feature}</span>
</li>
{/each}
</ul>
{/if}
<LandingButton variant="primary" size="lg" href={pricing.ctaHref ?? '#enroll'} class={t.pricingCta}>
{pricing.ctaLabel}
</LandingButton>
{#if pricing.reward?.show}
<div class={t.pricingRewardShell}>
<div class="ui:flex ui:items-center ui:gap-2 ui:mb-1">
<GiftIcon class="ui:size-4 ui:text-[var(--landing-accent)]" />
<span class={t.pricingRewardLabel}>{labels?.pricingRewardLabel ?? 'Bonus'}</span>
</div>
<p>{pricing.reward.description}</p>
</div>
{/if}
</div>
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,84 @@
<script lang="ts">
import type { CourseLandingPageLabels, CourseReviewItem, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import EditableLandingSection from './editable-section.svelte';
import StarIcon from '@lucide/svelte/icons/star';
interface Props {
variant: OrgLandingPageTheme;
reviews: { items: CourseReviewItem[]; averageRating?: number };
labels?: CourseLandingPageLabels;
}
let { variant, reviews, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
const computedAverage = $derived(
reviews.averageRating ??
(reviews.items.length > 0 ? reviews.items.reduce((sum, r) => sum + r.rating, 0) / reviews.items.length : null)
);
const ratingLabel = $derived(
computedAverage !== null && reviews.items.length > 0
? (labels?.reviewsAverageLabel?.(reviews.items.length) ??
`Based on ${reviews.items.length} ${reviews.items.length === 1 ? 'review' : 'reviews'}`)
: null
);
function dateLabel(value?: string): string {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return value;
return d.toLocaleDateString(undefined, { month: 'short', year: 'numeric' });
}
</script>
{#if reviews.items.length > 0}
<EditableLandingSection sectionKey="reviews">
<section id="reviews" class={t.sectionShell}>
<div class={t.sectionInner}>
<div class={t.sectionHeader}>
<span class={t.eyebrow}>{labels?.reviewsEyebrow ?? 'Reviews'}</span>
<h2 class={t.heading}>{labels?.reviewsHeading ?? 'What students say'}</h2>
<span class={t.headingRule} aria-hidden="true"></span>
</div>
{#if computedAverage !== null}
<div class={t.reviewsAverage}>
<span class={t.reviewsAverageValue}>{computedAverage.toFixed(1)}</span>
<div class={t.reviewStars}>
{#each Array(5) as _, i}
<StarIcon
class={`${t.reviewStar} ${i < Math.round(computedAverage) ? 'ui:fill-current' : 'ui:opacity-30'}`}
/>
{/each}
</div>
{#if ratingLabel}
<span class={t.reviewsAverageLabel}>· {ratingLabel}</span>
{/if}
</div>
{/if}
<div class={t.reviewsGrid}>
{#each reviews.items as review (review.id)}
<article class={t.reviewCard}>
<div class={t.reviewStars}>
{#each Array(5) as _, i}
<StarIcon class={`${t.reviewStar} ${i < review.rating ? 'ui:fill-current' : 'ui:opacity-30'}`} />
{/each}
</div>
<p class={t.reviewQuote}>{review.description}</p>
<div class="ui:flex ui:items-center ui:justify-between">
<span class={t.reviewName}>{review.name}</span>
{#if review.createdAt}
<span class={t.reviewDate}>{dateLabel(review.createdAt)}</span>
{/if}
</div>
</article>
{/each}
</div>
</div>
</section>
</EditableLandingSection>
{/if}
@@ -0,0 +1,71 @@
<script lang="ts">
import type { CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import * as UnderlineTabs from '../underline-tabs';
interface SectionNavItem {
/** DOM id of the section to scroll to (without leading #). */
id: string;
label: string;
}
interface Props {
variant: OrgLandingPageTheme;
items: SectionNavItem[];
labels?: CourseLandingPageLabels;
}
let { variant, items }: Props = $props();
const t = $derived(courseLandingTokens(variant));
let activeId = $state<string>('');
$effect(() => {
if (typeof window === 'undefined') return;
if (items.length === 0) return;
const elements = items.map(({ id }) => document.getElementById(id)).filter((el): el is HTMLElement => el !== null);
if (elements.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]) activeId = visible[0].target.id;
},
{
rootMargin: '-120px 0px -55% 0px',
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
});
function handleScroll(id: string) {
const el = document.getElementById(id);
if (!el) return;
const top = el.getBoundingClientRect().top + window.scrollY - 24;
window.scrollTo({ top, behavior: 'smooth' });
}
</script>
{#if items.length > 1}
<nav class={t.sectionNavShell}>
<div class={t.sectionNavInner}>
<UnderlineTabs.Root bind:value={activeId} class="ui:w-full">
<UnderlineTabs.List class={t.sectionNavList}>
{#each items as item (item.id)}
<UnderlineTabs.Trigger value={item.id} class={t.sectionNavItem} onclick={() => handleScroll(item.id)}>
{item.label}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
</UnderlineTabs.Root>
</div>
</nav>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import type { CourseSocialProof, CourseLandingPageLabels, OrgLandingPageTheme } from './types';
import { courseLandingTokens } from './course-landing-page.tokens';
import StarIcon from '@lucide/svelte/icons/star';
import BookOpenIcon from '@lucide/svelte/icons/book-open';
import LayoutListIcon from '@lucide/svelte/icons/layout-list';
import AwardIcon from '@lucide/svelte/icons/award';
interface Props {
variant: OrgLandingPageTheme;
socialProof: CourseSocialProof;
labels?: CourseLandingPageLabels;
}
let { variant, socialProof, labels }: Props = $props();
const t = $derived(courseLandingTokens(variant));
const items = $derived(
[
socialProof.rating !== undefined
? {
key: 'rating',
value: socialProof.rating.toFixed(1),
label: labels?.socialProofRatingLabel ?? 'Rating',
Icon: StarIcon
}
: null,
socialProof.lessons !== undefined
? {
key: 'lessons',
value: socialProof.lessons.toString(),
label: labels?.socialProofLessonsLabel ?? 'Lessons',
Icon: BookOpenIcon
}
: null,
socialProof.type
? {
key: 'type',
value: socialProof.type,
label: labels?.socialProofTypeLabel ?? 'Format',
Icon: LayoutListIcon
}
: null,
socialProof.hasCertificate
? {
key: 'certificate',
value: 'Included',
label: labels?.socialProofCertificateLabel ?? 'Certificate',
Icon: AwardIcon
}
: null
].filter(Boolean) as Array<{
key: string;
value: string;
label: string;
Icon: typeof StarIcon;
}>
);
</script>
<section class={t.socialProofShell}>
<div class="ui:max-w-[1200px] ui:mx-auto">
<div class="ui:flex ui:flex-wrap ui:items-center ui:justify-around ui:gap-6 ui:md:gap-10">
{#each items as item, idx (item.key)}
<div class={t.socialProofItem}>
<div class="ui:flex ui:items-center ui:gap-2">
<item.Icon class="ui:size-4 ui:text-[var(--landing-accent)]" />
<span class={t.socialProofValue}>{item.value}</span>
</div>
<span class={t.socialProofLabel}>{item.label}</span>
</div>
{#if idx < items.length - 1}
<span class={t.socialProofDivider} aria-hidden="true"></span>
{/if}
{/each}
</div>
</div>
</section>
@@ -0,0 +1,228 @@
export type CourseLandingTokens = {
sectionShell: string;
sectionInner: string;
sectionHeader: string;
eyebrow: string;
heading: string;
body: string;
headingRule: string;
socialProofShell: string;
socialProofItem: string;
socialProofValue: string;
socialProofLabel: string;
socialProofDivider: string;
infoGrid: string;
infoBlock: string;
infoBlockEyebrow: string;
infoBlockHeading: string;
infoBlockBody: string;
infoCertificateFrame: string;
certificateShell: string;
certificateFrame: string;
sectionNavShell: string;
sectionNavInner: string;
sectionNavList: string;
sectionNavItem: string;
sectionNavItemActive: string;
chipsSectionShell: string;
chipsGroupHeading: string;
chipsGroup: string;
chip: string;
chipsShowAll: string;
curriculumContainer: string;
curriculumModule: string;
curriculumModuleHeader: string;
curriculumModuleTitle: string;
curriculumModuleMeta: string;
curriculumLessonRow: string;
curriculumLessonPrefix: string;
curriculumLessonTitle: string;
curriculumLessonIcon: string;
instructorShell: string;
instructorAvatarWrap: string;
instructorBodyWrap: string;
instructorAvatar: string;
instructorName: string;
instructorRole: string;
instructorMeta: string;
instructorBody: string;
reviewsAverage: string;
reviewsAverageValue: string;
reviewsAverageLabel: string;
reviewsGrid: string;
reviewCard: string;
reviewStars: string;
reviewStar: string;
reviewQuote: string;
reviewName: string;
reviewDate: string;
pricingShell: string;
pricingHeader: string;
pricingEyebrow: string;
pricingAmount: string;
pricingCurrency: string;
pricingDiscount: string;
pricingFreeBadge: string;
pricingFeatures: string;
pricingFeature: string;
pricingFeatureIcon: string;
pricingCta: string;
pricingRewardShell: string;
pricingRewardLabel: string;
pricingSavingsBadge: string;
};
export const baseCourseTokens: CourseLandingTokens = {
sectionShell: 'ui:py-12 ui:md:py-14 ui:px-4',
sectionInner: 'ui:max-w-[1200px] ui:mx-auto',
sectionHeader: 'ui:flex ui:flex-col ui:gap-2 ui:mb-6',
eyebrow:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
heading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]' +
' ui:[text-transform:var(--landing-heading-case)]',
body: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed',
headingRule: 'ui:hidden',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofItem: 'ui:flex ui:flex-col ui:items-center ui:gap-1 ui:text-[var(--landing-fg)]',
socialProofValue: 'ui:text-2xl ui:[font-weight:var(--landing-heading-weight)]',
socialProofLabel:
'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:[letter-spacing:var(--landing-eyebrow-tracking)] ui:[text-transform:var(--landing-eyebrow-case)]',
socialProofDivider: 'ui:w-px ui:h-10 ui:bg-[var(--landing-border)] ui:opacity-60',
infoGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-12',
infoBlock: 'ui:flex ui:flex-col ui:gap-3' + ' ui:pt-8 ui:border-t ui:border-[var(--landing-border)]',
infoBlockEyebrow:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
infoBlockHeading:
'ui:text-2xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
infoBlockBody:
'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed' +
' ui:[&_p]:mb-2 ui:[&_ul]:list-disc ui:[&_ul]:pl-5 ui:[&_li]:mb-1',
infoCertificateFrame:
'ui:overflow-hidden ui:border ui:border-[var(--landing-border)]' + ' ui:[border-radius:var(--landing-radius-card)]',
certificateShell:
'ui:p-6 ui:md:p-8 ui:border ui:border-[var(--landing-border)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:bg-[var(--landing-card)] ui:[box-shadow:var(--landing-shadow-card)]',
certificateFrame:
'ui:overflow-hidden ui:border ui:border-[var(--landing-border)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:max-w-[260px] ui:w-full',
sectionNavShell: 'ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]',
sectionNavInner: 'ui:max-w-[1200px] ui:mx-auto ui:px-4',
sectionNavList: 'ui:border-b-0 ui:w-full',
sectionNavItem: '',
sectionNavItemActive: '',
chipsSectionShell: 'ui:flex ui:flex-col ui:gap-10',
chipsGroupHeading:
'ui:text-2xl ui:md:text-3xl ui:text-[var(--landing-fg)] ui:mb-5' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
chipsGroup: 'ui:flex ui:flex-wrap ui:gap-2.5 ui:items-center',
chip:
'ui:inline-flex ui:items-center ui:px-4 ui:py-2 ui:text-sm ui:text-[var(--landing-fg)]' +
' ui:border ui:border-[var(--landing-border)] ui:bg-transparent' +
' ui:[border-radius:var(--landing-radius-pill)]' +
' ui:hover:border-[var(--landing-accent)] ui:transition-colors',
chipsShowAll:
'ui:text-sm ui:text-[var(--landing-fg)] ui:underline ui:underline-offset-4' +
' ui:[font-weight:var(--landing-heading-weight)] ui:cursor-pointer ui:ml-2 ui:bg-transparent ui:border-0',
curriculumContainer: 'ui:flex ui:flex-col',
curriculumModule: 'ui:border-t ui:border-[var(--landing-border)]' + ' ui:last:border-b ui:py-2',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:py-4 ui:cursor-pointer ui:text-left' +
' ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
curriculumModuleTitle:
'ui:text-base ui:md:text-lg ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
curriculumModuleMeta: 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:font-mono',
curriculumLessonRow:
'ui:flex ui:items-center ui:gap-3 ui:py-3 ui:pl-2' + ' ui:border-t ui:border-[var(--landing-border)]/60',
curriculumLessonPrefix: 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:font-mono ui:min-w-[3ch]',
curriculumLessonTitle: 'ui:text-sm ui:text-[var(--landing-fg)]',
curriculumLessonIcon: 'ui:text-[var(--landing-fg-muted)]',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start' +
' ui:p-6 ui:md:p-8 ui:border ui:border-[var(--landing-border)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:[box-shadow:var(--landing-shadow-card)]',
instructorAvatarWrap: 'ui:shrink-0',
instructorBodyWrap: 'ui:flex-1 ui:p-6',
instructorAvatar: 'ui:size-20 ui:rounded-full ui:object-cover ui:border ui:border-[var(--landing-border)]',
instructorName: 'ui:text-xl ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
instructorRole: 'ui:text-sm ui:text-[var(--landing-fg-muted)]',
instructorMeta: 'ui:text-xs ui:text-[var(--landing-fg-faint)] ui:mt-1',
instructorBody: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:mt-3',
reviewsAverage: 'ui:flex ui:items-center ui:gap-3 ui:mb-8 ui:text-[var(--landing-fg)]',
reviewsAverageValue: 'ui:text-3xl ui:[font-weight:var(--landing-heading-weight)]',
reviewsAverageLabel: 'ui:text-sm ui:text-[var(--landing-fg-muted)]',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-4',
reviewCard:
'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:[box-shadow:var(--landing-shadow-card)]',
reviewStars: 'ui:flex ui:items-center ui:gap-0.5 ui:mb-3',
reviewStar: 'ui:text-[var(--landing-accent)] ui:size-4',
reviewQuote: 'ui:text-sm ui:text-[var(--landing-fg)] ui:leading-relaxed ui:mb-4',
reviewName: 'ui:text-sm ui:text-[var(--landing-fg)] ui:font-medium',
reviewDate: 'ui:text-xs ui:text-[var(--landing-fg-muted)]',
pricingShell:
'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:[box-shadow:var(--landing-shadow-card)]',
pricingHeader: 'ui:flex ui:flex-col ui:gap-2 ui:mb-6',
pricingEyebrow:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
pricingAmount:
'ui:text-4xl ui:md:text-5xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
pricingCurrency: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:font-normal ui:ml-1',
pricingDiscount: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:line-through',
pricingFreeBadge:
'ui:inline-block ui:px-3 ui:py-1 ui:text-xs ui:text-[var(--landing-accent-fg)]' +
' ui:bg-[var(--landing-accent)]' +
' ui:[border-radius:var(--landing-radius-pill)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
pricingFeatures: 'ui:flex ui:flex-col ui:gap-2 ui:mb-6',
pricingFeature: 'ui:flex ui:items-center ui:gap-2 ui:text-sm ui:text-[var(--landing-fg)]',
pricingFeatureIcon: 'ui:text-[var(--landing-accent)]',
pricingCta: 'ui:w-full',
pricingRewardShell:
'ui:mt-4 ui:p-3 ui:border ui:border-dashed ui:border-[var(--landing-border)]' +
' ui:[border-radius:var(--landing-radius-card)]' +
' ui:text-xs ui:text-[var(--landing-fg-muted)]',
pricingRewardLabel: 'ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)]',
pricingSavingsBadge: 'ui:text-xs ui:text-[var(--landing-accent)]' + ' ui:[font-weight:var(--landing-heading-weight)]'
};
export function tokenize(overrides: Partial<CourseLandingTokens>): CourseLandingTokens {
return { ...baseCourseTokens, ...overrides };
}
@@ -2,10 +2,32 @@ import { getContext, setContext } from 'svelte';
import type { Component } from 'svelte';
/**
* Sections in the org landing page that the editor allows you to click in the preview
* to open. Matches the keys used by the editor's section list.
* Sections that an editor allows you to click in the preview to open the
* corresponding form. Includes both the org landing page sections AND the
* course landing page sections — the same `EditableLandingSection` wrapper
* is used in both contexts.
*
* Org sections: hero, navigation, callout, embed, links, footer.
* Course sections: header (course hero), requirement, description, goals,
* certificate, curriculum, chips (skills/tools), instructor, reviews, pricing.
*/
export type LandingSectionKey = 'hero' | 'navigation' | 'callout' | 'embed' | 'links' | 'footer';
export type LandingSectionKey =
| 'hero'
| 'navigation'
| 'callout'
| 'embed'
| 'links'
| 'footer'
| 'header'
| 'requirement'
| 'description'
| 'goals'
| 'certificate'
| 'curriculum'
| 'chips'
| 'instructor'
| 'reviews'
| 'pricing';
export interface LandingPageEditContext {
/** Reactive read of the currently selected section (or null if none). */
@@ -0,0 +1,68 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-12 ui:md:py-14 ui:px-4 ui:bg-[var(--landing-bg)]',
eyebrow:
'ui:text-xs ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
heading:
'ui:text-4xl ui:md:text-5xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
body:
'ui:text-lg ui:text-[var(--landing-fg-muted)] ui:leading-relaxed' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofValue:
'ui:text-3xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofLabel:
'ui:text-xs ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
infoBlock: 'ui:flex ui:flex-col ui:gap-3 ui:pt-10 ui:border-t ui:border-[var(--landing-border)]',
infoBlockHeading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
infoBlockBody:
'ui:text-base ui:md:text-lg ui:text-[var(--landing-fg)] ui:leading-relaxed' +
' ui:first-letter:text-5xl ui:first-letter:[font-weight:var(--landing-heading-weight)]' +
' ui:first-letter:float-left ui:first-letter:mr-2 ui:first-letter:leading-none' +
' ui:first-letter:text-[var(--landing-accent)]',
curriculumModule: 'ui:border-t ui:border-[var(--landing-border)] ui:py-2 ui:last:border-b',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:py-5 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-baseline ui:md:justify-between ui:md:gap-4',
curriculumModuleTitle:
'ui:text-2xl ui:md:text-3xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumModuleMeta:
'ui:text-xs ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
curriculumLessonPrefix:
'ui:text-base ui:text-[var(--landing-accent)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
reviewCard: 'ui:p-0 ui:border-t ui:border-[var(--landing-border)] ui:pt-6 ui:bg-transparent',
reviewStar: 'ui:text-[var(--landing-accent)] ui:size-4',
reviewQuote:
'ui:text-base ui:italic ui:text-[var(--landing-fg)] ui:leading-relaxed ui:mb-4' +
' ui:before:content-["“"] ui:before:text-[var(--landing-accent)] ui:before:text-2xl ui:before:mr-1',
instructorShell: 'ui:flex ui:flex-col ui:md:flex-row ui:gap-8 ui:items-start',
instructorAvatar: 'ui:size-32 ui:rounded-full ui:object-cover',
instructorName:
'ui:text-2xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
instructorRole:
'ui:text-sm ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
pricingShell: 'ui:p-8 ui:border-t-2 ui:border-b-2 ui:border-[var(--landing-fg)] ui:bg-[var(--landing-bg)]',
pricingAmount:
'ui:text-5xl ui:md:text-6xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import EditorialNav from './nav.svelte';
import EditorialHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('editorial')}
>
<main>
<EditorialHero hero={heroProps} courses={[]}>
{#snippet navigation()}
<EditorialNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</EditorialHero>
<CourseSectionNav variant="editorial" items={sectionNavItems} {labels} />
<CourseSocialProof variant="editorial" {socialProof} {labels} />
<CourseInfoBlocks variant="editorial" {info} {labels} />
<CourseCurriculum variant="editorial" {curriculum} {labels} />
{#if chips}
<CourseChips variant="editorial" {chips} {labels} />
{/if}
<CourseInstructor variant="editorial" {instructor} {labels} />
<CourseReviews variant="editorial" {reviews} {labels} />
<CoursePricing variant="editorial" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="editorial" />
</div>
@@ -11,9 +11,10 @@
courses: OrgLandingPageProps['courses'];
labels?: OrgLandingPageProps['labels'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, courses = [], labels, navigation }: Props = $props();
let { hero, courses = [], labels, navigation, showActions = true }: Props = $props();
const featured = $derived(courses[0]);
const featuredTypeMeta = $derived(featured ? getCourseTypeLandingMeta(featured) : undefined);
@@ -71,23 +72,25 @@
{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>
{#if showActions}
<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>
{/if}
</div>
{#if featured}
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-4';
@@ -1,15 +1,15 @@
<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';
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 './nav.svelte';
import EditorialHero from './hero.svelte';
import EditorialCourseCard from './course-card.svelte';
import { themeStyle } from '../theme-style';
import { Button } from '../../../base/button';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
let {
orgName,
@@ -0,0 +1,36 @@
import type { LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
'--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',
'--landing-heading-weight': '500',
'--landing-heading-tracking': '-0.025em',
'--landing-heading-case': 'none',
'--landing-eyebrow-tracking': '0.16em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '0px',
'--landing-radius-pill': '9999px',
'--landing-shadow-card': 'none',
'--landing-divider': '1px solid color-mix(in oklab, #1a1a1a, transparent 80%)',
'--landing-mono-family': 'ui-serif, Georgia, serif',
'--landing-glow': 'none',
'--landing-panel-bg-accent': 'color-mix(in oklab, #c25237, #f4f3ed 85%)'
};
@@ -1,10 +1,10 @@
import type { OrgLandingPageProps } from './types';
import type { CourseLandingPageProps, OrgLandingPageProps } from './types';
export const mockOrgLandingPageProps: OrgLandingPageProps = {
orgName: 'Certifi Academy',
logoUrl: 'https://ui-avatars.com/api/?name=CA&background=0f172a&color=fff',
navItems: [
{ label: 'Programs', href: '#courses' },
{ label: 'Courses', href: '/courses' },
{ label: 'About', href: '#about' },
{ label: 'Contact', href: '#contact' },
{ label: 'Sign In', href: '#signin' }
@@ -265,3 +265,172 @@ export const mockOrgLandingPageProps: OrgLandingPageProps = {
}
}
};
export const mockCourseLandingPageProps: CourseLandingPageProps = {
theme: 'minimal',
orgName: mockOrgLandingPageProps.orgName,
logoUrl: mockOrgLandingPageProps.logoUrl,
navItems: mockOrgLandingPageProps.navItems,
authAction: mockOrgLandingPageProps.authAction,
hero: {
heading: 'Information Security Certification',
subheading:
'A practitioner-led path to SOC 2 and ISO 27001 readiness — built around access controls, incident response, and the policies auditors actually look for.',
primaryAction: { label: 'Enroll now', href: '#pricing' },
secondaryAction: { label: 'View curriculum', href: '#curriculum' },
image: 'https://images.unsplash.com/photo-1550751827-4bd374c3f58b?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80',
stats: [
{ label: 'Lessons', value: '24' },
{ label: 'Rating', value: '4.8' },
{ label: 'Certificate', value: 'Included' }
]
},
socialProof: {
rating: 4.8,
lessons: 24,
type: 'Self-paced',
hasCertificate: true
},
info: {
requirements:
'<ul><li>Working familiarity with a corporate IT environment.</li><li>Comfort reading vendor security questionnaires.</li><li>No prior audit experience required — we start from policy basics.</li></ul>',
description:
'<p>This program walks you through every control domain assessors examine, with worked examples drawn from real audit findings. You will produce a complete control matrix, an incident-response runbook, and an evidence-collection plan you can hand to your auditor on day one.</p>',
goals:
'<ul><li>Design an access-control model that maps cleanly to SOC 2 CC6.</li><li>Run a tabletop incident-response exercise and capture the evidence trail auditors expect.</li><li>Stand up a continuous-monitoring loop using the tools you already have.</li></ul>',
certificateUrl:
'https://images.unsplash.com/photo-1606326608606-aa0b62935f2b?ixlib=rb-4.0.3&auto=format&fit=crop&w=1200&q=80'
},
chips: {
skills: [
'SOC 2 controls',
'Access reviews',
'Incident response',
'ISO 27001 mapping',
'Evidence collection',
'Risk register',
'Vendor security',
'Policy authoring',
'Tabletop exercises',
'Audit readiness',
'Continuous monitoring',
'Joiner-mover-leaver'
],
tools: ['Vanta', 'Drata', 'Okta', 'Jira', 'Notion', 'Slack', 'AWS IAM']
},
curriculum: {
grouped: true,
sections: [
{
id: 'sec-1',
title: '1 · Foundations of Information Security',
exerciseCount: 2,
lessons: [
{ id: 'l-1', title: 'CIA triad and the modern threat landscape', durationMinutes: 12 },
{ id: 'l-2', title: 'Reading a SOC 2 report end-to-end', durationMinutes: 18 },
{ id: 'l-3', title: 'Mapping ISO 27001 Annex A to your controls', durationMinutes: 22 },
{ id: 'l-4', title: 'Lab: classify a sample data inventory', durationMinutes: 25 }
]
},
{
id: 'sec-2',
title: '2 · Access Controls & Identity',
exerciseCount: 3,
lessons: [
{ id: 'l-5', title: 'Designing RBAC for a 200-person org', durationMinutes: 20 },
{ id: 'l-6', title: 'MFA, SSO, and the JIT access pattern', durationMinutes: 16 },
{ id: 'l-7', title: 'Joiner-mover-leaver automation', durationMinutes: 22 },
{ id: 'l-8', title: 'Lab: audit a real privileged-access log', durationMinutes: 30 }
]
},
{
id: 'sec-3',
title: '3 · Incident Response & Evidence',
exerciseCount: 2,
lessons: [
{ id: 'l-9', title: 'Writing an IR runbook auditors trust', durationMinutes: 18 },
{ id: 'l-10', title: 'Tabletop exercise: ransomware in week one', durationMinutes: 35 },
{ id: 'l-11', title: 'Evidence collection without breaking velocity', durationMinutes: 20 }
]
},
{
id: 'sec-4',
title: '4 · Continuous Monitoring',
exerciseCount: 2,
lessons: [
{ id: 'l-12', title: 'Building a monitoring loop with what you own', durationMinutes: 24 },
{ id: 'l-13', title: 'Quarterly access reviews that finish on time', durationMinutes: 18 },
{ id: 'l-14', title: 'Capstone: ship a control matrix and IR plan', durationMinutes: 45 }
]
}
]
},
instructor: {
name: 'Amelia Okonkwo',
role: 'Director of Security Engineering, Lattice',
imgUrl:
'https://images.unsplash.com/photo-1573497019940-1c28c88b4f3e?ixlib=rb-4.0.3&auto=format&fit=crop&w=400&q=80',
description:
'Amelia has led SOC 2 Type II and ISO 27001 audits at four SaaS companies and built the security programs that passed them. She teaches the parts of compliance work that the textbooks skip.',
coursesNo: 5
},
reviews: {
averageRating: 4.8,
items: [
{
id: 'r-1',
name: 'Daniel R.',
avatarUrl: 'https://i.pravatar.cc/120?img=15',
rating: 5,
description:
'The IR runbook template alone paid for the course twice over. Walked into our Type II audit with zero scramble.',
createdAt: '2026-02-14'
},
{
id: 'r-2',
name: 'Priya S.',
avatarUrl: 'https://i.pravatar.cc/120?img=32',
rating: 5,
description:
'Best material I have seen on access reviews that actually finish. The joiner-mover-leaver lab is gold.',
createdAt: '2026-02-02'
},
{
id: 'r-3',
name: 'Marcus B.',
avatarUrl: 'https://i.pravatar.cc/120?img=8',
rating: 4,
description: 'Dense in the best way. Skipped almost no theory but every lesson ends with something you ship.',
createdAt: '2026-01-21'
},
{
id: 'r-4',
name: 'Helene D.',
avatarUrl: 'https://i.pravatar.cc/120?img=47',
rating: 5,
description:
'Finally a security course that respects my time. I finished it on a flight to Lisbon and shipped the capstone the next morning.',
createdAt: '2026-01-09'
}
]
},
pricing: {
cost: 99,
currency: 'USD',
discount: 15,
showDiscount: true,
ctaLabel: 'Enroll now',
ctaHref: '#enroll',
features: [
'Lifetime access to all lessons',
'Auditor-ready IR runbook template',
'Capstone review by the instructor',
'Certificate of completion'
],
reward: {
show: true,
description: 'Refer a teammate and unlock the bonus "Vendor risk in a week" module.'
}
},
footer: mockOrgLandingPageProps.footer
};
@@ -1,43 +1,74 @@
export { default as MinimalLandingPage } from './minimal.svelte';
export { default as BoldLandingPage } from './bold.svelte';
export { default as ClassicLandingPage } from './classic.svelte';
export { default as SaasLandingPage } from './saas.svelte';
export { default as TechLandingPage } from './tech.svelte';
export { default as StudioLandingPage } from './studio.svelte';
export { default as CorporateLandingPage } from './corporate.svelte';
export { default as TerminalLandingPage } from './terminal.svelte';
export { default as 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';
export { default as ClassicLandingHero } from './classic/hero.svelte';
export { default as MinimalLandingNav } from './minimal/nav.svelte';
export { default as MinimalLandingHero } from './minimal/hero.svelte';
export { default as SaasLandingNav } from './saas/nav.svelte';
export { default as SaasLandingHero } from './saas/hero.svelte';
export { default as TechLandingNav } from './tech/nav.svelte';
export { default as TechLandingHero } from './tech/hero.svelte';
export { default as StudioLandingNav } from './studio/nav.svelte';
export { default as StudioLandingHero } from './studio/hero.svelte';
export { default as CorporateLandingNav } from './corporate/nav.svelte';
export { default as CorporateLandingHero } from './corporate/hero.svelte';
export { default as TerminalLandingNav } from './terminal/nav.svelte';
export { default as TerminalLandingHero } from './terminal/hero.svelte';
export { default as 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 {
org as MinimalLandingPage,
course as MinimalCourseLanding,
nav as MinimalLandingNav,
hero as MinimalLandingHero,
courseCard as MinimalLandingCourseCard
} from './minimal';
export {
org as BoldLandingPage,
course as BoldCourseLanding,
nav as BoldLandingNav,
hero as BoldLandingHero,
courseCard as BoldLandingCourseCard
} from './bold';
export {
org as ClassicLandingPage,
course as ClassicCourseLanding,
nav as ClassicLandingNav,
hero as ClassicLandingHero,
courseCard as ClassicLandingCourseCard
} from './classic';
export {
org as SaasLandingPage,
course as SaasCourseLanding,
nav as SaasLandingNav,
hero as SaasLandingHero,
courseCard as SaasLandingCourseCard
} from './saas';
export {
org as TechLandingPage,
course as TechCourseLanding,
nav as TechLandingNav,
hero as TechLandingHero,
courseCard as TechLandingCourseCard
} from './tech';
export {
org as StudioLandingPage,
course as StudioCourseLanding,
nav as StudioLandingNav,
hero as StudioLandingHero,
courseCard as StudioLandingCourseCard
} from './studio';
export {
org as CorporateLandingPage,
course as CorporateCourseLanding,
nav as CorporateLandingNav,
hero as CorporateLandingHero,
courseCard as CorporateLandingCourseCard
} from './corporate';
export {
org as TerminalLandingPage,
course as TerminalCourseLanding,
nav as TerminalLandingNav,
hero as TerminalLandingHero,
courseCard as TerminalLandingCourseCard
} from './terminal';
export {
org as EditorialLandingPage,
course as EditorialCourseLanding,
nav as EditorialLandingNav,
hero as EditorialLandingHero,
courseCard as EditorialLandingCourseCard
} from './editorial';
export {
org as VibrantLandingPage,
course as VibrantCourseLanding,
nav as VibrantLandingNav,
hero as VibrantLandingHero,
courseCard as VibrantLandingCourseCard
} from './vibrant';
export { default as LandingButton } from './landing-button.svelte';
export { default as OrgLandingPageEmbed } from './embed.svelte';
export { default as OrgLandingPageCallout } from './callout.svelte';
@@ -45,13 +76,22 @@ export { default as OrgLandingPageLinks } from './links.svelte';
export { default as OrgLandingPageFooter } from './landing-page-footer.svelte';
export { default as SecondaryActionButton } from './secondary-action-button.svelte';
export { default as EditableLandingSection } from './editable-section.svelte';
export { default as CourseLandingSocialProof } from './course-social-proof.svelte';
export { default as CourseLandingInfoBlocks } from './course-info-blocks.svelte';
export { default as CourseLandingCurriculum } from './course-curriculum.svelte';
export { default as CourseLandingSectionNav } from './course-section-nav.svelte';
export { default as CourseLandingChips } from './course-chips.svelte';
export { default as CourseLandingInstructor } from './course-instructor.svelte';
export { default as CourseLandingReviews } from './course-reviews.svelte';
export { default as CourseLandingPricing } from './course-pricing.svelte';
export {
setLandingPageEditContext,
getLandingPageEditContext,
type LandingSectionKey,
type LandingPageEditContext
} from './edit-context';
export { courseLandingTokens, type CourseLandingTokens } from './course-landing-page.tokens';
export * from './landing-page-link-icons';
export * from './theme-style';
export * from './types';
export { mockOrgLandingPageProps } from './fixtures';
export { mockOrgLandingPageProps, mockCourseLandingPageProps } from './fixtures';
@@ -195,7 +195,7 @@ export function getFooterTokens(variant: OrgLandingPageTheme): FooterRegionToken
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',
'ui:font-bold ui:text-2xl ui:md:text-3xl 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:
@@ -0,0 +1,3 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import MinimalNav from './nav.svelte';
import MinimalHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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={heroProps}>
{#snippet navigation()}
<MinimalNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</MinimalHero>
<CourseSectionNav variant="minimal" items={sectionNavItems} {labels} />
<CourseSocialProof variant="minimal" {socialProof} {labels} />
<CourseInfoBlocks variant="minimal" {info} {labels} />
<CourseCurriculum variant="minimal" {curriculum} {labels} />
{#if chips}
<CourseChips variant="minimal" {chips} {labels} />
{/if}
<CourseInstructor variant="minimal" {instructor} {labels} />
<CourseReviews variant="minimal" {reviews} {labels} />
<CoursePricing variant="minimal" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="minimal" />
</div>
@@ -10,9 +10,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, navigation }: Props = $props();
let { hero, navigation, showActions = true }: Props = $props();
const heroGradientBackground = `
radial-gradient(
@@ -47,23 +48,25 @@
>
{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.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="minimal"
/>
{/if}
</div>
{#if showActions}
<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>
{/if}
</div>
</section>
</EditableLandingSection>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:gap-6';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import MinimalNav from './minimal/nav.svelte';
import MinimalHero from './minimal/hero.svelte';
import MinimalCourseCard from './minimal/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import MinimalNav from './nav.svelte';
import MinimalHero from './hero.svelte';
import MinimalCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,5 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars
};
@@ -0,0 +1,29 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-12 ui:md:py-14 ui:px-4 ui:bg-[var(--landing-bg)]',
infoGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:gap-6',
infoBlock:
'ui:flex ui:flex-col ui:gap-3 ui:p-8' +
' ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:gap-4',
reviewCard: 'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
curriculumContainer:
'ui:border ui:border-[var(--landing-border)]' + ' ui:[border-radius:var(--landing-radius-card)] ui:overflow-hidden',
curriculumModule: 'ui:border-t ui:border-[var(--landing-border)] ui:first:border-t-0',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:px-6 ui:py-4 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
curriculumLessonRow:
'ui:flex ui:items-center ui:gap-3 ui:px-6 ui:py-3' + ' ui:border-t ui:border-[var(--landing-border)]/60',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
pricingShell:
'ui:p-8 ui:border ui:border-[var(--landing-border)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:[box-shadow:var(--landing-shadow-card)]',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start ui:p-8' +
' ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
instructorAvatarWrap: 'ui:shrink-0',
instructorBodyWrap: 'ui:flex-1',
instructorAvatar: 'ui:size-24 ui:rounded-full ui:object-cover ui:border ui:border-[var(--landing-border)]',
instructorBody: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:mt-3'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import SaasNav from './nav.svelte';
import SaasHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('saas')}
>
<main>
<SaasHero hero={heroProps}>
{#snippet navigation()}
<SaasNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</SaasHero>
<CourseSectionNav variant="saas" items={sectionNavItems} {labels} />
<CourseSocialProof variant="saas" {socialProof} {labels} />
<CourseInfoBlocks variant="saas" {info} {labels} />
<CourseCurriculum variant="saas" {curriculum} {labels} />
{#if chips}
<CourseChips variant="saas" {chips} {labels} />
{/if}
<CourseInstructor variant="saas" {instructor} {labels} />
<CourseReviews variant="saas" {reviews} {labels} />
<CoursePricing variant="saas" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="saas" />
</div>
@@ -8,9 +8,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, navigation }: Props = $props();
let { hero, navigation, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -32,19 +33,21 @@
{hero.subheading}
</p>
<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 showActions}
<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}
{#if hero.image}
<div class="ui:mt-16 ui:max-w-4xl ui:mx-auto">
@@ -0,0 +1,8 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass =
'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-px ui:bg-[var(--landing-border)] ui:border ui:border-[var(--landing-border)]';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import SaasNav from './saas/nav.svelte';
import SaasHero from './saas/hero.svelte';
import SaasCourseCard from './saas/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import SaasNav from './nav.svelte';
import SaasHero from './hero.svelte';
import SaasCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -41,9 +41,11 @@
{#if courses.length > 0}
<div class="plus-row"></div>
<section class="ui:py-20 ui:px-6">
<section class="ui:py-12 ui:md:py-20 ui:px-4 ui:md:px-6">
<div class="ui:max-w-[1180px] ui:mx-auto">
<div class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-12">
<div
class="ui:flex ui:flex-col ui:md:flex-row ui:md:items-end ui:md:justify-between ui:gap-4 ui:mb-8 ui:md:mb-12"
>
<div>
<p
class="ui:text-xs ui:font-semibold ui:tracking-widest ui:uppercase ui:text-[var(--landing-accent)] ui:mb-3"
@@ -158,14 +160,23 @@
right: -8px;
}
/* ============ LINE-SEPARATED COURSE GRID ============ */
/* ============ LINE-SEPARATED COURSE GRID (mobile-first) ============ */
.saas-root :global(.course-grid) {
display: grid;
grid-template-columns: repeat(6, 1fr);
grid-template-columns: 1fr;
gap: 1px;
background: var(--landing-border);
border: 1px solid var(--landing-border);
}
@media (min-width: 640px) {
.saas-root :global(.course-grid) {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 1024px) {
.saas-root :global(.course-grid) {
grid-template-columns: repeat(3, 1fr);
}
}
.saas-root :global(.course-grid > .course-card-wrap) {
grid-column: span 2;
}
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '700',
'--landing-heading-tracking': '-0.01em',
'--landing-radius-card': '12px',
'--landing-shadow-card': '0 8px 24px -16px color-mix(in oklab, var(--landing-fg), transparent 85%)',
'--landing-divider': '1px solid var(--landing-border)'
};
@@ -61,7 +61,7 @@
</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"
class="ui:flex-shrink-0 ui:inline-flex ui:items-center ui:justify-center ui:size-8 ui:rounded-full ui:bg-secondary ui:text-secondary-foreground ui:group-hover:bg-secondary/80 ui:transition-colors"
aria-hidden="true"
aria-label={labels?.enrollLabel ?? 'Enroll'}
>
@@ -0,0 +1,26 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-12 ui:md:py-14 ui:px-4 ui:bg-[var(--landing-bg)]',
eyebrow:
'ui:flex ui:items-center ui:gap-1.5 ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:before:content-[""] ui:before:size-1.5 ui:before:rounded-full ui:before:bg-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofItem:
'ui:flex ui:items-center ui:gap-2 ui:px-4 ui:py-3' +
' ui:[border-radius:var(--landing-radius-card)] ui:border ui:border-[var(--landing-border)]/60',
socialProofDivider: 'ui:hidden',
infoBlock:
'ui:flex ui:flex-col ui:gap-3 ui:p-6' +
' ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
curriculumModule:
'ui:mb-3 ui:p-4' + ' ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
curriculumLessonRow: 'ui:flex ui:items-center ui:gap-3 ui:py-2 ui:pl-4 ui:text-sm',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start ui:p-6' +
' ui:border ui:border-[var(--landing-border)] ui:[border-radius:var(--landing-radius-card)]',
instructorAvatar: 'ui:size-16 ui:object-cover ui:[border-radius:var(--landing-radius-card)]',
reviewCard: 'ui:p-5 ui:border ui:border-[var(--landing-border)]' + ' ui:[border-radius:var(--landing-radius-card)]',
pricingShell: 'ui:p-6 ui:border ui:border-[var(--landing-border)]' + ' ui:[border-radius:var(--landing-radius-card)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import StudioNav from './nav.svelte';
import StudioHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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={heroProps} courses={[]}>
{#snippet navigation()}
<StudioNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</StudioHero>
<CourseSectionNav variant="studio" items={sectionNavItems} {labels} />
<CourseSocialProof variant="studio" {socialProof} {labels} />
<CourseInfoBlocks variant="studio" {info} {labels} />
<CourseCurriculum variant="studio" {curriculum} {labels} />
{#if chips}
<CourseChips variant="studio" {chips} {labels} />
{/if}
<CourseInstructor variant="studio" {instructor} {labels} />
<CourseReviews variant="studio" {reviews} {labels} />
<CoursePricing variant="studio" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="studio" />
</div>
@@ -9,9 +9,10 @@
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, courses = [], navigation }: Props = $props();
let { hero, courses = [], navigation, showActions = true }: Props = $props();
const programsCount = $derived(courses.length);
@@ -58,18 +59,24 @@
{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"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="studio" />
{/if}
</div>
{#if showActions}
<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>
{/if}
{#if hero.image}
<div
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-3';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import StudioNav from './studio/nav.svelte';
import StudioHero from './studio/hero.svelte';
import StudioCourseCard from './studio/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import StudioNav from './nav.svelte';
import StudioHero from './hero.svelte';
import StudioCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,8 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '600',
'--landing-radius-card': '8px',
'--landing-divider': '1px solid color-mix(in oklab, var(--landing-border), transparent 50%)'
};
@@ -0,0 +1,58 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
sectionNavItem: 'ui:font-mono',
eyebrow:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
heading:
'ui:text-3xl ui:md:text-5xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
body: 'ui:text-base ui:text-[var(--landing-fg-muted)] ui:leading-relaxed ui:font-mono',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-dashed ui:border-[var(--landing-border)]',
socialProofValue:
'ui:text-3xl ui:font-mono ui:text-[var(--landing-accent)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
socialProofLabel:
'ui:text-xs ui:font-mono ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
socialProofDivider: 'ui:text-[var(--landing-fg-muted)] ui:font-mono',
infoGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-2 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
infoBlock: 'ui:flex ui:flex-col ui:gap-3 ui:p-8 ui:border-r ui:border-b ui:border-[var(--landing-border)]',
infoBlockEyebrow:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]' + ' ui:[letter-spacing:var(--landing-eyebrow-tracking)]',
curriculumContainer: 'ui:border-t ui:border-l ui:border-[var(--landing-border)]',
curriculumModule: 'ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:py-0',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:px-6 ui:py-4 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
curriculumModuleMeta:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)] ui:px-2 ui:py-1' +
' ui:border ui:border-[var(--landing-border)]',
curriculumLessonRow:
'ui:flex ui:items-center ui:gap-3 ui:px-6 ui:py-3 ui:border-t ui:border-dashed ui:border-[var(--landing-border)]',
curriculumLessonPrefix: 'ui:text-xs ui:text-[var(--landing-fg-muted)] ui:font-mono',
reviewsGrid: 'ui:grid ui:grid-cols-1 ui:md:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]',
reviewCard: 'ui:p-6 ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]',
reviewName: 'ui:text-sm ui:font-mono ui:text-[var(--landing-fg)] ui:font-medium',
reviewDate: 'ui:text-xs ui:font-mono ui:text-[var(--landing-fg-muted)]',
chip:
'ui:inline-flex ui:items-center ui:px-3 ui:py-1.5 ui:text-xs ui:font-mono' +
' ui:bg-transparent ui:text-[var(--landing-fg)] ui:border ui:border-[var(--landing-border)]' +
' ui:hover:border-[var(--landing-accent)] ui:hover:text-[var(--landing-accent)]' +
' ui:transition-colors',
chipsShowAll:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]' +
' ui:cursor-pointer ui:ml-2 ui:bg-transparent ui:border-0',
instructorShell:
'ui:grid ui:grid-cols-1 ui:md:grid-cols-[auto_1fr] ui:border-t ui:border-l ui:border-[var(--landing-border)]',
instructorAvatarWrap:
'ui:border-r ui:border-b ui:border-[var(--landing-border)] ui:p-6 ui:flex ui:items-center ui:justify-center',
instructorAvatar: 'ui:size-24 ui:object-cover ui:rounded-none',
pricingShell: 'ui:p-6 ui:border ui:border-[var(--landing-border)] ui:bg-[var(--landing-card)]',
pricingAmount: 'ui:text-5xl ui:font-mono ui:text-[var(--landing-fg)] ui:[font-weight:var(--landing-heading-weight)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import TechNav from './nav.svelte';
import TechHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<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={heroProps}>
{#snippet navigation()}
<TechNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</TechHero>
<CourseSectionNav variant="tech" items={sectionNavItems} {labels} />
<CourseSocialProof variant="tech" {socialProof} {labels} />
<CourseInfoBlocks variant="tech" {info} {labels} />
<CourseCurriculum variant="tech" {curriculum} {labels} />
{#if chips}
<CourseChips variant="tech" {chips} {labels} />
{/if}
<CourseInstructor variant="tech" {instructor} {labels} />
<CourseReviews variant="tech" {reviews} {labels} />
<CoursePricing variant="tech" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="tech" />
</div>
@@ -8,9 +8,10 @@
interface Props {
hero: OrgLandingPageProps['hero'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, navigation }: Props = $props();
let { hero, navigation, showActions = true }: Props = $props();
</script>
<EditableLandingSection sectionKey="hero">
@@ -45,20 +46,26 @@
>
{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"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton href={hero.secondaryAction.href} label={hero.secondaryAction.label} variant="tech" />
{/if}
</div>
{#if showActions}
<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"
>
{hero.primaryAction.label}
</Button>
{#if hero.secondaryAction}
<SecondaryActionButton
href={hero.secondaryAction.href}
label={hero.secondaryAction.label}
variant="tech"
/>
{/if}
</div>
{/if}
</div>
{#if hero.image}
@@ -0,0 +1,8 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass =
'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:border-t ui:border-l ui:border-[var(--landing-border)]';
@@ -1,14 +1,14 @@
<script lang="ts">
import type { OrgLandingPageProps } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TechNav from './tech/nav.svelte';
import TechHero from './tech/hero.svelte';
import TechCourseCard from './tech/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import type { OrgLandingPageProps } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import TechNav from './nav.svelte';
import TechHero from './hero.svelte';
import TechCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
let {
orgName,
@@ -0,0 +1,13 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '800',
'--landing-heading-tracking': '-0.01em',
'--landing-eyebrow-tracking': '0.18em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '0px',
'--landing-radius-pill': '0px',
'--landing-divider': '1px dashed var(--landing-border)',
'--landing-mono-family': 'ui-monospace, "JetBrains Mono", "Fira Code", monospace'
};
@@ -0,0 +1,89 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-12 ui:md:py-14 ui:px-4 ui:bg-[var(--landing-bg)]',
sectionNavShell: 'ui:border-b ui:border-[var(--landing-border)] ui:bg-[var(--landing-bg)]',
sectionNavItem: 'ui:font-mono',
eyebrow:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
heading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)] ui:font-mono' +
' ui:[font-weight:var(--landing-heading-weight)]',
body: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:font-mono ui:leading-relaxed',
socialProofShell: 'ui:py-8 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofValue:
'ui:text-2xl ui:font-mono ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
socialProofLabel:
'ui:text-xs ui:font-mono ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
socialProofDivider:
'ui:size-2 ui:rounded-full ui:bg-[var(--landing-accent)]' + ' ui:[box-shadow:var(--landing-glow)]',
infoBlock:
'ui:flex ui:flex-col ui:gap-3 ui:p-6' +
' ui:[background:linear-gradient(180deg,var(--landing-card)_0%,var(--landing-bg-section)_100%)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-card)]',
infoBlockHeading:
'ui:text-xl ui:font-mono ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
infoBlockBody: 'ui:text-sm ui:font-mono ui:text-[var(--landing-fg-muted)] ui:leading-relaxed',
curriculumContainer:
'ui:p-2' +
' ui:[background:linear-gradient(180deg,var(--landing-card)_0%,var(--landing-bg-section)_100%)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-card)]',
curriculumModule: 'ui:py-2',
curriculumModuleHeader:
'ui:flex ui:items-center ui:justify-between ui:gap-4 ui:px-4 ui:py-3 ui:cursor-pointer' +
' ui:font-mono ui:text-[var(--landing-fg)]',
curriculumModuleTitle:
'ui:text-base ui:font-mono ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
curriculumModuleMeta: 'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]',
curriculumLessonRow:
'ui:flex ui:items-center ui:gap-3 ui:px-4 ui:py-2 ui:font-mono ui:text-sm' +
' ui:[border-top:1px_dashed_var(--landing-border-soft)]',
curriculumLessonPrefix: 'ui:text-xs ui:text-[var(--landing-accent)] ui:font-mono',
curriculumLessonTitle: 'ui:text-sm ui:text-[var(--landing-fg-muted)] ui:font-mono',
reviewCard:
'ui:p-6' +
' ui:[background:linear-gradient(180deg,var(--landing-card)_0%,var(--landing-bg-section)_100%)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-card)]',
reviewName: 'ui:text-sm ui:font-mono ui:text-[var(--landing-fg)]',
reviewDate: 'ui:text-xs ui:font-mono ui:text-[var(--landing-fg-muted)]',
reviewQuote: 'ui:text-sm ui:font-mono ui:text-[var(--landing-fg)] ui:leading-relaxed ui:mb-4',
chip:
'ui:inline-flex ui:items-center ui:px-3 ui:py-1.5 ui:text-xs ui:font-mono' +
' ui:bg-transparent ui:text-[var(--landing-fg)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-pill)]' +
' ui:hover:border-[var(--landing-border)] ui:transition-colors',
chipsShowAll:
'ui:text-xs ui:font-mono ui:text-[var(--landing-accent)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]' +
' ui:cursor-pointer ui:ml-2 ui:bg-transparent ui:border-0',
instructorShell:
'ui:flex ui:flex-col ui:md:flex-row ui:gap-6 ui:items-start ui:p-6' +
' ui:[background:linear-gradient(180deg,var(--landing-card)_0%,var(--landing-bg-section)_100%)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-card)]',
instructorAvatar: 'ui:size-20 ui:rounded-full ui:object-cover ui:border ui:border-[var(--landing-border-soft)]',
instructorName:
'ui:text-xl ui:font-mono ui:text-[var(--landing-fg)]' + ' ui:[font-weight:var(--landing-heading-weight)]',
instructorRole: 'ui:text-sm ui:font-mono ui:text-[var(--landing-fg-muted)]',
pricingShell:
'ui:p-6' +
' ui:[background:linear-gradient(180deg,var(--landing-card)_0%,var(--landing-bg-section)_100%)]' +
' ui:border ui:border-[var(--landing-border-soft)]' +
' ui:[border-radius:var(--landing-radius-card)]',
pricingAmount:
'ui:text-4xl ui:md:text-5xl ui:font-mono ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]',
pricingFreeBadge:
'ui:inline-block ui:px-3 ui:py-1 ui:text-xs ui:font-mono ui:text-[var(--landing-accent-fg)]' +
' ui:bg-[var(--landing-accent)] ui:[border-radius:var(--landing-radius-pill)]' +
' ui:[box-shadow:var(--landing-glow)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import TerminalNav from './nav.svelte';
import TerminalHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</script>
<div
class="ui:min-h-screen ui:bg-[var(--landing-bg)] ui:text-[var(--landing-fg)] ui:font-sans"
style={themeStyle('terminal')}
>
<main>
<TerminalHero {orgName} hero={heroProps} courses={[]}>
{#snippet navigation()}
<TerminalNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</TerminalHero>
<CourseSectionNav variant="terminal" items={sectionNavItems} {labels} />
<CourseSocialProof variant="terminal" {socialProof} {labels} />
<CourseInfoBlocks variant="terminal" {info} {labels} />
<CourseCurriculum variant="terminal" {curriculum} {labels} />
{#if chips}
<CourseChips variant="terminal" {chips} {labels} />
{/if}
<CourseInstructor variant="terminal" {instructor} {labels} />
<CourseReviews variant="terminal" {reviews} {labels} />
<CoursePricing variant="terminal" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="terminal" />
</div>
@@ -9,9 +9,10 @@
hero: OrgLandingPageProps['hero'];
courses: OrgLandingPageProps['courses'];
navigation: Snippet;
showActions?: boolean;
}
let { orgName = '', hero, courses = [], navigation }: Props = $props();
let { orgName = '', hero, courses = [], navigation, showActions = true }: Props = $props();
const programsCount = $derived(courses.length);
const learnersCount = $derived(courses.reduce((sum, course) => sum + (course.totalStudents ?? 0), 0));
@@ -90,26 +91,28 @@
{hero.heading}
</h1>
<div class="ui:inline-flex ui:items-center ui:gap-2 ui:mb-8">
{#if hero.secondaryAction}
{#if showActions}
<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(--landing-border)] ui:text-[var(--landing-fg)] ui:hover:bg-white/10"
>
{hero.secondaryAction.label}
</Button>
{/if}
<Button
href={hero.secondaryAction.href}
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="sm"
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"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
>
{hero.secondaryAction.label}
{hero.primaryAction.label}
</Button>
{/if}
<Button
href={hero.primaryAction.href}
disabled={hero.primaryAction.disabled ?? false}
size="sm"
class="ui:rounded-full ui:px-4 ui:font-medium ui:bg-[#f4f5f7] ui:text-[#0a0b0e] ui:hover:bg-white"
>
{hero.primaryAction.label}
</Button>
</div>
</div>
{/if}
<p class="ui:text-[15px] ui:mb-7 ui:max-w-xl ui:mx-auto ui:text-[var(--landing-fg-muted)]">
{hero.subheading}
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-[14px]';
@@ -1,15 +1,15 @@
<script lang="ts">
import type { OrgLandingPageProps, CourseItem } from './types';
import OrgLandingPageEmbed from './embed.svelte';
import OrgLandingPageLinks from './links.svelte';
import OrgLandingPageCallout from './callout.svelte';
import OrgLandingPageFooter from './landing-page-footer.svelte';
import TerminalNav from './terminal/nav.svelte';
import TerminalHero from './terminal/hero.svelte';
import TerminalCourseCard from './terminal/course-card.svelte';
import { Button } from '../../base/button';
import { themeStyle } from './theme-style';
import { getCourseTypeLandingMeta } from './landing-page-utils';
import type { OrgLandingPageProps, CourseItem } from '../types';
import OrgLandingPageEmbed from '../embed.svelte';
import OrgLandingPageLinks from '../links.svelte';
import OrgLandingPageCallout from '../callout.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import TerminalNav from './nav.svelte';
import TerminalHero from './hero.svelte';
import TerminalCourseCard from './course-card.svelte';
import { Button } from '../../../base/button';
import { themeStyle } from '../theme-style';
import { getCourseTypeLandingMeta } from '../landing-page-utils';
let {
orgName,
@@ -0,0 +1,36 @@
import type { LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
'--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)',
'--landing-heading-weight': '600',
'--landing-heading-tracking': 'normal',
'--landing-heading-case': 'none',
'--landing-eyebrow-tracking': '0.12em',
'--landing-eyebrow-case': 'uppercase',
'--landing-radius-card': '14px',
'--landing-radius-pill': '9999px',
'--landing-shadow-card': 'none',
'--landing-divider': '1px solid #1a1c22',
'--landing-mono-family': 'ui-monospace, "JetBrains Mono", "Fira Code", monospace',
'--landing-glow': '0 0 12px color-mix(in oklab, var(--landing-accent), transparent 30%)',
'--landing-panel-bg-accent': 'color-mix(in oklab, var(--landing-accent), #06070a 88%)'
};
@@ -1,105 +1,29 @@
import type { OrgLandingPageTheme } from './types';
import { baseTokenVars, type LandingThemeVars } from './theme-vars-base';
import { vars as minimalVars } from './minimal/vars';
import { vars as boldVars } from './bold/vars';
import { vars as classicVars } from './classic/vars';
import { vars as saasVars } from './saas/vars';
import { vars as techVars } from './tech/vars';
import { vars as studioVars } from './studio/vars';
import { vars as corporateVars } from './corporate/vars';
import { vars as terminalVars } from './terminal/vars';
import { vars as editorialVars } from './editorial/vars';
import { vars as vibrantVars } from './vibrant/vars';
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 type { LandingThemeVarName, LandingThemeVars } from './theme-vars-base';
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'
}
minimal: minimalVars,
bold: boldVars,
classic: classicVars,
saas: saasVars,
tech: techVars,
studio: studioVars,
corporate: corporateVars,
vibrant: vibrantVars,
terminal: terminalVars,
editorial: editorialVars
};
export function themeStyle(theme: OrgLandingPageTheme): string {
@@ -0,0 +1,70 @@
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'
| '--landing-heading-weight'
| '--landing-heading-tracking'
| '--landing-heading-case'
| '--landing-eyebrow-tracking'
| '--landing-eyebrow-case'
| '--landing-radius-card'
| '--landing-radius-pill'
| '--landing-shadow-card'
| '--landing-divider'
| '--landing-mono-family'
| '--landing-glow'
| '--landing-panel-bg-accent';
export type LandingThemeVars = Record<LandingThemeVarName, string>;
export 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)',
'--landing-heading-weight': '600',
'--landing-heading-tracking': 'normal',
'--landing-heading-case': 'none',
'--landing-eyebrow-tracking': '0.04em',
'--landing-eyebrow-case': 'none',
'--landing-radius-card': '0px',
'--landing-radius-pill': '9999px',
'--landing-shadow-card': 'none',
'--landing-divider': '1px solid color-mix(in oklab, var(--landing-border), transparent 40%)',
'--landing-mono-family': 'ui-sans-serif, system-ui, sans-serif',
'--landing-glow': 'none',
'--landing-panel-bg-accent': 'color-mix(in oklab, var(--landing-accent), var(--landing-bg) 88%)'
};
@@ -1,5 +1,19 @@
import type { Component } from 'svelte';
export type NavItem = { label: string; href: string };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyComponent = Component<any>;
export type LandingPageThemeBundle = {
nav: AnyComponent;
hero: AnyComponent;
courseCard: AnyComponent;
org: AnyComponent;
course: AnyComponent;
coursesGridClass: string;
};
export type OrgLandingPageTheme =
| 'minimal'
| 'bold'
@@ -166,6 +180,146 @@ export interface OrgLandingPageLabels {
learnMoreLabel?: string;
}
export type CourseCurriculumLesson = {
id: string;
title: string;
durationMinutes?: number;
};
export type CourseCurriculumSection = {
id: string;
title: string;
lessons: CourseCurriculumLesson[];
exerciseCount?: number;
};
export type CourseReviewItem = {
id: string;
name: string;
avatarUrl?: string;
rating: number;
description: string;
createdAt?: string;
};
export type CourseInstructor = {
name: string;
role?: string;
imgUrl?: string;
description?: string;
coursesNo?: number;
};
export type CoursePricing = {
cost: number;
currency: string;
discount?: number;
showDiscount?: boolean;
ctaLabel: string;
ctaHref?: string;
features?: string[];
reward?: {
show: boolean;
description: string;
};
};
export type CourseSocialProof = {
rating?: number;
lessons?: number;
type?: string;
hasCertificate?: boolean;
};
export type CourseInfoBlocks = {
requirements?: string;
description?: string;
goals?: string;
certificateUrl?: string;
};
export type CourseChipSections = {
skills?: string[];
tools?: string[];
};
export type CourseCurriculum = {
grouped: boolean;
sections: CourseCurriculumSection[];
};
export interface CourseLandingPageLabels {
socialProofRatingLabel?: string;
socialProofLessonsLabel?: string;
socialProofTypeLabel?: string;
socialProofCertificateLabel?: string;
curriculumEyebrow?: string;
curriculumHeading?: string;
lessonsLabel?: (count: number) => string;
exercisesLabel?: (count: number) => string;
lecturePrefix?: (index: number) => string;
infoRequirementsHeading?: string;
infoRequirementsEyebrow?: string;
infoDescriptionHeading?: string;
infoDescriptionEyebrow?: string;
infoGoalsHeading?: string;
infoGoalsEyebrow?: string;
infoCertificateHeading?: string;
infoCertificateEyebrow?: string;
instructorEyebrow?: string;
instructorHeading?: string;
instructorCoursesLabel?: (count: number) => string;
reviewsEyebrow?: string;
reviewsHeading?: string;
reviewsAverageLabel?: (count: number) => string;
pricingEyebrow?: string;
freeLabel?: string;
pricingRewardLabel?: string;
chipsSkillsEyebrow?: string;
chipsSkillsHeading?: string;
chipsToolsEyebrow?: string;
chipsToolsHeading?: string;
chipsShowAllLabel?: string;
chipsShowLessLabel?: string;
navAboutLabel?: string;
navCurriculumLabel?: string;
navSkillsLabel?: string;
navInstructorLabel?: string;
navReviewsLabel?: string;
navPricingLabel?: string;
}
export interface CourseLandingPageProps {
theme: OrgLandingPageTheme;
orgName: string;
logoUrl?: string;
navItems: NavItem[];
authAction?: {
label: string;
href: string;
};
hero: {
heading: string;
subheading: string;
primaryAction: { label: string; href: string; disabled?: boolean };
secondaryAction?: { label: string; href: string };
image?: string;
stats?: Array<{ label: string; value: string }>;
};
socialProof: CourseSocialProof;
info: CourseInfoBlocks;
curriculum: CourseCurriculum;
chips?: CourseChipSections;
instructor: CourseInstructor;
reviews: {
items: CourseReviewItem[];
averageRating?: number;
};
pricing: CoursePricing;
footer: OrgLandingPageFooterConfig;
labels?: CourseLandingPageLabels;
}
export interface OrgLandingPageProps {
orgName: string;
logoUrl?: string;
@@ -25,7 +25,7 @@
<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
class="ui:group ui:flex ui:flex-col ui:h-full ui:overflow-hidden ui:rounded-[20px] ui:border ui:border-[var(--landing-border)] 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}
@@ -0,0 +1,59 @@
import { tokenize, type CourseLandingTokens } from '../course-tokens-base';
export const courseTokens: CourseLandingTokens = tokenize({
sectionShell: 'ui:py-14 ui:md:py-16 ui:px-4 ui:bg-[var(--landing-bg)]',
heading:
'ui:text-5xl ui:md:text-7xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofShell: 'ui:py-10 ui:px-4 ui:border-y ui:border-[var(--landing-border)]',
socialProofValue:
'ui:text-5xl ui:md:text-6xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
socialProofLabel:
'ui:text-xs ui:text-[var(--landing-fg-muted)]' +
' ui:[letter-spacing:var(--landing-eyebrow-tracking)]' +
' ui:[text-transform:var(--landing-eyebrow-case)]',
socialProofDivider: 'ui:hidden',
infoBlock: 'ui:flex ui:flex-col ui:gap-4',
infoBlockHeading:
'ui:text-4xl ui:md:text-5xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
infoBlockBody:
'ui:text-lg ui:text-[var(--landing-fg-muted)] ui:leading-relaxed' +
' ui:[&_p]:mb-2 ui:[&_ul]:list-disc ui:[&_ul]:pl-5 ui:[&_li]:mb-1',
curriculumModule: 'ui:mb-6 ui:border-b ui:border-[var(--landing-border)] ui:pb-6 ui:last:border-b-0',
curriculumModuleHeader:
'ui:flex ui:flex-col ui:items-start ui:gap-2 ui:cursor-pointer ui:text-left ui:md:flex-row ui:md:items-center ui:md:justify-between ui:md:gap-4',
curriculumModuleTitle:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
curriculumLessonRow: 'ui:flex ui:items-center ui:gap-3 ui:py-3 ui:border-t ui:border-[var(--landing-border)]/50',
instructorShell: 'ui:flex ui:flex-col ui:md:flex-row ui:gap-8 ui:items-start',
instructorAvatar: 'ui:size-32 ui:rounded-full ui:object-cover',
instructorName:
'ui:text-4xl ui:md:text-5xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
reviewCard: 'ui:p-0 ui:border-t ui:border-[var(--landing-border)] ui:pt-6 ui:bg-transparent',
chip:
'ui:inline-flex ui:items-center ui:px-5 ui:py-2.5 ui:text-base ui:text-[var(--landing-fg)]' +
' ui:border ui:border-[var(--landing-border)] ui:bg-transparent' +
' ui:[border-radius:var(--landing-radius-pill)]' +
' ui:hover:border-[var(--landing-accent)]' +
' ui:transition-colors',
chipsGroupHeading:
'ui:text-3xl ui:md:text-4xl ui:text-[var(--landing-fg)] ui:mb-6' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]',
pricingShell:
'ui:p-10 ui:border-2 ui:border-[var(--landing-fg)]' +
' ui:[border-radius:var(--landing-radius-card)] ui:bg-[var(--landing-bg)]',
pricingAmount:
'ui:text-6xl ui:md:text-7xl ui:text-[var(--landing-fg)]' +
' ui:[font-weight:var(--landing-heading-weight)]' +
' ui:[letter-spacing:var(--landing-heading-tracking)]'
});
@@ -0,0 +1,64 @@
<script lang="ts">
import type { CourseLandingPageProps } from '../types';
import VibrantNav from './nav.svelte';
import VibrantHero from './hero.svelte';
import OrgLandingPageFooter from '../landing-page-footer.svelte';
import CourseSocialProof from '../course-social-proof.svelte';
import CourseInfoBlocks from '../course-info-blocks.svelte';
import CourseCurriculum from '../course-curriculum.svelte';
import CourseChips from '../course-chips.svelte';
import CourseSectionNav from '../course-section-nav.svelte';
import CourseInstructor from '../course-instructor.svelte';
import CourseReviews from '../course-reviews.svelte';
import CoursePricing from '../course-pricing.svelte';
import { themeStyle } from '../theme-style';
import { alignHeroCtaWithPricing, buildCourseSectionNavItems } from '../course-landing-page.helpers';
let {
orgName,
logoUrl,
navItems,
authAction,
hero,
socialProof,
info,
curriculum,
chips,
instructor,
reviews,
pricing,
footer,
labels
}: CourseLandingPageProps = $props();
const heroProps = $derived(alignHeroCtaWithPricing(hero, pricing));
const sectionNavItems = $derived(
buildCourseSectionNavItems({ info, curriculum, chips, instructor, reviews }, labels)
);
</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={heroProps}>
{#snippet navigation()}
<VibrantNav {orgName} {logoUrl} {navItems} {authAction} />
{/snippet}
</VibrantHero>
<CourseSectionNav variant="vibrant" items={sectionNavItems} {labels} />
<CourseSocialProof variant="vibrant" {socialProof} {labels} />
<CourseInfoBlocks variant="vibrant" {info} {labels} />
<CourseCurriculum variant="vibrant" {curriculum} {labels} />
{#if chips}
<CourseChips variant="vibrant" {chips} {labels} />
{/if}
<CourseInstructor variant="vibrant" {instructor} {labels} />
<CourseReviews variant="vibrant" {reviews} {labels} />
<CoursePricing variant="vibrant" {pricing} {labels} />
</main>
<OrgLandingPageFooter {orgName} {logoUrl} {footer} variant="vibrant" />
</div>
@@ -9,9 +9,10 @@
hero: OrgLandingPageProps['hero'];
labels?: OrgLandingPageProps['labels'];
navigation: Snippet;
showActions?: boolean;
}
let { hero, labels, navigation }: Props = $props();
let { hero, labels, navigation, showActions = true }: Props = $props();
</script>
{@render navigation()}
@@ -35,22 +36,24 @@
{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>
{#if showActions}
<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>
{/if}
</div>
</section>
@@ -0,0 +1,7 @@
export { default as nav } from './nav.svelte';
export { default as hero } from './hero.svelte';
export { default as courseCard } from './course-card.svelte';
export { default as org } from './org.svelte';
export { default as course } from './course.svelte';
export const coursesGridClass = 'ui:grid ui:grid-cols-1 ui:@xl:grid-cols-2 ui:@4xl:grid-cols-3 ui:gap-5';
@@ -1,15 +1,15 @@
<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';
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 './nav.svelte';
import VibrantHero from './hero.svelte';
import VibrantCourseCard from './course-card.svelte';
import { themeStyle } from '../theme-style';
import { Button } from '../../../base/button';
import { getCourseTypeLandingMeta, defaultLessonsLabel, defaultExercisesLabel } from '../landing-page-utils';
let {
orgName,
@@ -0,0 +1,10 @@
import { baseTokenVars, type LandingThemeVars } from '../theme-vars-base';
export const vars: LandingThemeVars = {
...baseTokenVars,
'--landing-heading-weight': '500',
'--landing-heading-tracking': '-0.04em',
'--landing-radius-card': '24px',
'--landing-divider': '1px solid color-mix(in oklab, var(--landing-border), transparent 50%)',
'--landing-panel-bg-accent': 'color-mix(in oklab, var(--landing-accent), var(--landing-bg) 80%)'
};
@@ -15,7 +15,11 @@
import Callout from './callout.svelte';
import type { PublicCourseCalloutAnimation, PublicCourseCalloutData, PublicExerciseViewData } from './types';
import type { PublicExerciseStoredAttempt } from './public-exercise-attempts-storage';
import { readPublicExerciseAttempts, writePublicExerciseAttempts } from './public-exercise-attempts-storage';
import {
cloneAnswersByKey,
readPublicExerciseAttempts,
writePublicExerciseAttempts
} from './public-exercise-attempts-storage';
interface Props {
exercise: PublicExerciseViewData;
@@ -77,7 +81,7 @@
const defaultIndex = rows.length - 1;
selectValue = String(defaultIndex);
const row = rows[defaultIndex];
answers = structuredClone(row.answersByKey);
answers = cloneAnswersByKey(row.answersByKey);
submitted = true;
});
});
@@ -95,7 +99,7 @@
selectValue = selected;
submitted = true;
answers = structuredClone(persistedAttempts[idx].answersByKey);
answers = cloneAnswersByKey(persistedAttempts[idx].answersByKey);
}
function handleAnswerChange(question: ExerciseQuestionModel, answer: AnswerData | null) {
@@ -317,7 +321,7 @@
const snapshot: PublicExerciseStoredAttempt = {
savedAt: new Date().toISOString(),
answersByKey: structuredClone(answers),
answersByKey: cloneAnswersByKey(answers),
correctCount: correct,
totalGradable: tot
};
@@ -11,6 +11,7 @@ export { default as PublicCourseCallout } from './callout.svelte';
export {
PUBLIC_EXERCISE_ATTEMPTS_STORE_VERSION,
cloneAnswersByKey,
publicExerciseAttemptsStorageKey,
readPublicExerciseAttempts,
writePublicExerciseAttempts
@@ -9,13 +9,51 @@
lesson: PublicLessonViewData;
/** Shown in the player captions menu; supplied by the host app (`$t(...)`) — see authenticated `lesson-video-player`. */
videoCaptionsLabel: string;
/**
* For HLS videos. Host app provides a callback that mints the public
* HLS cookie via `POST /org-site/course/.../hls-cookie`. The shared
* media player invokes it before hls.js loads the manifest, and again
* via `onPlaybackReload` when the learner clicks "Reload playback"
* after a cookie-expiry / segment failure.
*/
onBeforeHlsLoad?: () => Promise<void>;
/**
* Resolves a relative HLS URL (e.g. `/hls/{assetId}/master.m3u8`) into
* an absolute URL the browser can fetch. Host app passes a resolver
* that knows the API base URL (typically built from the typed client).
*/
resolveHlsUrl?: (link: string) => string;
/**
* Strings shown on the player's reload-after-failure surface. Defaults
* are English; host app should localise via `$t(...)` keys.
*/
playbackErrorLabel?: string;
playbackReloadLabel?: string;
callout?: PublicCourseCalloutData | null;
calloutAnimation?: PublicCourseCalloutAnimation;
class?: string;
id?: string;
}
let { lesson, videoCaptionsLabel, callout = null, calloutAnimation, class: className, id }: Props = $props();
let {
lesson,
videoCaptionsLabel,
onBeforeHlsLoad,
resolveHlsUrl,
playbackErrorLabel,
playbackReloadLabel,
callout = null,
calloutAnimation,
class: className,
id
}: Props = $props();
const isHls = $derived(Boolean(lesson.video?.hls));
const playbackUrl = $derived.by(() => {
if (!lesson.video) return '';
if (isHls && resolveHlsUrl) return resolveHlsUrl(lesson.video.link);
return lesson.video.link;
});
const resolvedCalloutAnimation = $derived<PublicCourseCalloutAnimation>(
calloutAnimation ?? callout?.animation ?? 'waves'
@@ -52,11 +90,12 @@
<Callout variant="full" {callout} animation={resolvedCalloutAnimation} />
{:else}
{#if lesson.video}
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:bg-muted ui:rounded-lg">
<div class="ui:mb-6 ui:w-full ui:overflow-hidden ui:rounded-lg">
<MediaPlayer
source={{
type: lesson.video.type,
url: lesson.video.link,
url: playbackUrl,
hls: isHls,
metadata: {
...(lesson.video.metadata ?? {}),
title: lesson.video.metadata?.title ?? lesson.title
@@ -67,7 +106,11 @@
maxHeight: '569px',
width: '100%',
controls: true,
playsinline: true
playsinline: true,
onBeforeHlsLoad: isHls ? onBeforeHlsLoad : undefined,
playbackErrorLabel,
playbackReloadLabel,
onPlaybackReload: isHls ? async () => true : undefined
}}
/>
</div>
@@ -11,6 +11,11 @@ export interface PublicExerciseStoredAttempt {
totalGradable: number;
}
/** Plain deep clone — `structuredClone` throws on Svelte `$state` proxies. */
export function cloneAnswersByKey(answersByKey: Record<string, AnswerData>): Record<string, AnswerData> {
return JSON.parse(JSON.stringify(answersByKey)) as Record<string, AnswerData>;
}
interface PersistedEnvelope {
v: number;
attempts: PublicExerciseStoredAttempt[];
@@ -56,6 +56,14 @@ export interface PublicLessonViewData {
video: {
type: 'youtube' | 'generic' | 'upload' | 'google_drive';
link: string;
/**
* When true, `link` is an HLS master playlist URL served by the
* tenant-router Worker (prod) or API streaming route (local). The
* shared MediaPlayer attaches hls.js based on this flag. The host app
* must pass an `onBeforeHlsLoad` callback that mints a cookie via the
* public org-site endpoint before playback starts.
*/
hls?: boolean;
metadata?: {
svid?: string;
thumbnailUrl?: string;
@@ -12,6 +12,6 @@
</a>
<p class="ui:text-muted-foreground ui:text-sm">
Finally, A Course Platform That’s <i>Beautiful</i> & <i>Easy To Use</i>
The Most Customizable Platform for Customer, Partner, and Employee Education
</p>
</div>