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:
@@ -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';
|
||||
+11
-11
@@ -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';
|
||||
+10
-10
@@ -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)]';
|
||||
+10
-10
@@ -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';
|
||||
+11
-11
@@ -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';
|
||||
+10
-10
@@ -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)]';
|
||||
+26
-15
@@ -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';
|
||||
+10
-10
@@ -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)]';
|
||||
+10
-10
@@ -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]';
|
||||
+11
-11
@@ -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';
|
||||
+11
-11
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user