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:
@@ -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%)'
|
||||
};
|
||||
Reference in New Issue
Block a user