* fix(dashboard): restore paid course payment modal on landing page Wire the existing PaymentModal back into the theme-based course landing page. Paid course CTAs now open the name/email modal and redirect to the configured payment link instead of navigating to the blocked enroll route. Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): reset payment modal on close without effect loop Co-authored-by: Rotimi Best <[email protected]> * docs: add Svelte 5 rerender and effect loop guidance to AGENTS.md Co-authored-by: Rotimi Best <[email protected]> * fix(dashboard): avoid double-applying discount on course landing pricing Co-authored-by: Rotimi Best <[email protected]> --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Rotimi Best <[email protected]>
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:
- Reads the org's chosen theme from
landingpage.theme. - Maps
course + org→CourseLandingPagePropsviabuildCourseLandingPageProps(). Section visibility (metadata.sectionDisplay) is honoured here — hidden sections are stripped frominfo/reviews/instructorbefore rendering. - 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:
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'scourse.svelteas a template; the body sequence is the same). -
vars.ts— exportsvars: LandingThemeVars. Spread...baseTokenVarsand override the differences. Use raw hex only for genuinely off-palette themes (seeterminal/vars.ts,editorial/vars.ts). -
course-tokens.ts— exportscourseTokens: CourseLandingTokens = tokenize({ ... }). Only override regions that differ from the base. -
index.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:
import { vars as newthemeVars } from './newtheme/vars';
…
export const LANDING_THEME_VARS = {
…,
newtheme: newthemeVars
};
course-landing-page.tokens.ts:
import { courseTokens as newthemeTokens } from './newtheme/course-tokens';
…
const TOKENS = {
…,
newtheme: newthemeTokens
};
index.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'toimportThemeBundle(this is the only lazy-load switch —importThemeComponent,importThemeNavHero,importThemeCourseCard, andimportCourseLandingPageThemeare thin adapters over it). - If your hero composes
{@render navigation()}, add the theme key toNAV_SNIPPET_THEMES. - Add an entry to
themeCourseGridClass.
6. Pick body layouts
course-curriculum.sveltehas alayoutByThememap (accordionfor collapsible,chaptersfor permanently expanded). Addnewtheme.- 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 errorfrom the repo root.
That's it — one folder, two registry hooks, two Storybook entries.