# Onboarding Platform Showcase PRD ## Status - Draft ## Date - May 20, 2026 ## Purpose Redesign the new-user onboarding flow so creators see (a) what their public-facing **org landing page** can look like and pick a theme, and (b) land in a workspace that is already pre-populated with **goal-relevant demo courses** instead of an empty `/courses` page. The goal is to move first impression from "blank slate" to "this platform is alive and ready for me." ## Problem Statement Today's onboarding is a sterile 2-step form: 1. Org name + slug + full name 2. Goal + source + locale Then the user is dropped onto `/org/{slug}/courses?create=true` — a page with zero courses, zero branding decisions made, and no visible evidence that ClassroomIO is anything more than a CRUD form for a course list. Two problems compound: 1. **The landing page feature is invisible.** We ship 8 production-quality landing page themes (`minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal`) with full preview PNGs, but new users never see them — they have to discover Settings → Landing Page on their own. 2. **The workspace looks empty.** New users have no reference for what a "real" ClassroomIO course looks like (sections, lessons, video embeds, certificates). They have to imagine the product from a blank canvas. Result: low activation, high time-to-first-meaningful-action, and the impression that the platform is thinner than it actually is. ## ICP (Primary) 1. SaaS founders launching a product academy. 2. Independent course creators selling self-paced cohorts. 3. Operations leaders running employee/compliance training. 4. Customer-success teams running customer enablement. ## Target Jobs-To-Be-Done 1. Quickly evaluate whether ClassroomIO can produce a school site that looks like the creator's brand. 2. See what a "good" course structure looks like on the platform without having to build one to find out. 3. Get to a publishable-feeling state in a single session. ## Confirmed Product Decisions 1. **Theme picker** is a real step (not a passive preview gallery). The user's selection is persisted to `organization.landingPage.theme`. 2. **Auto-seed goal-relevant demo courses** — no extra picker step. The existing `goal` answer in metadata drives which course set lands in the org. 3. **Step order**: `org-setup → theme-picker (+ course preview) → user-metadata → seed-and-redirect`. 4. **Course source**: a **new curated demo set** authored in code (not the existing `seedCourses()` templates in `packages/db/src/utils/seed/course.ts`, which were built for E2E/dev seeding). ## Non-Goals (v1) - AI-generated courses from a URL — that is a separate initiative tracked in `prd/onboarding-ai-bootstrap/`. - Custom theme editing inside onboarding (colors, hero text, logo upload). The full landing-page editor stays in `/settings/landingpage`. - Letting the user pick *which* demo courses to seed. Goal-mapping is opinionated. - Multi-language demo course content. v1 demo courses ship in English only; the user's `locale` choice affects UI chrome but not seeded course text. - Surfacing demo courses on the **public** landing page automatically — they seed as unpublished/draft so the org owner reviews first. ## Data Sources Checked - `apps/dashboard/src/routes/(auth)/onboarding/+page.svelte` - `apps/dashboard/src/lib/features/onboarding/api/onboarding.svelte.ts` - `apps/dashboard/src/lib/features/onboarding/utils/constants.ts` - `apps/dashboard/src/lib/features/onboarding/utils/types.ts` - `apps/dashboard/src/lib/features/onboarding/components/verify-email-modal.svelte` - `apps/dashboard/src/lib/features/onboarding/components/welcome-modal.svelte` - `apps/dashboard/src/lib/features/org/utils/landing-page.ts` - `apps/dashboard/src/lib/utils/types/org.ts` - `apps/dashboard/src/lib/features/settings/pages/landingpage.svelte` - `apps/dashboard/static/templates/*.png` (8 theme preview PNGs) - `packages/ui/src/custom/org-landing-page/` (theme component implementations) - `apps/api/src/routes/onboarding/onboarding.ts` - `apps/api/src/services/onboarding.ts` - `apps/api/src/services/course/course.ts` (createCourse pattern) - `apps/api/src/services/course-import/course-import.ts` (bulk lesson/section seed pattern) - `apps/api/src/routes/organization/course-import.ts` - `packages/db/src/schema.ts` (organization, course, courseSection, lesson, courseImportDraft) - `packages/db/src/utils/seed/course.ts` (existing dev seed, reference only) - `packages/utils/src/validation/onboarding/onboarding.ts` - `prd/onboarding-ai-bootstrap/README.md` (sibling initiative, must not conflict) - `prd/org-landing-page-theme-picker [DONE]/README.md` (foundation shipped Apr 2026) ## Current Baseline (As-Is) 1. `ONBOARDING_STEPS = { ORG_SETUP: 1, USER_METADATA: 2 }` — two steps. 2. `createOrganizationWithOwner` creates the org with `landingPage = createDefaultLandingPageSettings()` (theme `'minimal'`, default hero copy from `defaultLandingPageHero` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:33`). 3. `updateUserOnboarding` saves goal/source/fullname, updates user locale, and the API class redirects to `/org/{siteName}?welcomePopup=...`. 4. No course creation happens during onboarding. The "create=true" query param on the courses page is unused. 5. Theme picker exists *only* in `/settings/landingpage` — never surfaced to first-time users. 6. The 8 theme preview PNGs at `apps/dashboard/static/templates/{theme}.png` are already shipped and used by the settings editor; we can reuse them as-is. --- ## Proposed Experience ### Step 1: Account + Org Basics (unchanged) - `fullname` - `orgName` - `siteName` On submit: existing `createOrganizationWithOwner` runs and the org is created with the default `'minimal'` theme. ### Step 2 (NEW): Theme Picker + Course Preview Single screen, two stacked sections inside the existing centered onboarding card. **Top section — "Pick a look for your school"** - Responsive 4-up grid (collapsing to 2-up on small screens) of 8 theme cards. - Each card: `/templates/{theme}.png` + theme display name + a subtle "Selected" ring when active. - Default selection: `minimal` (matches what was already saved at org-create time). - Clicking a card sets local `fields.theme`. No autosave — persistence happens on "Continue". **Bottom section — "Your workspace will come pre-loaded"** - 4 static course-tile mockups (using the same primitive `/courses` uses) showing variety: one `SELF_PACED`, one `LIVE_CLASS`, one `COMPLIANCE`, one with a certificate badge. - Subtext: "We'll add a few sample courses you can edit, publish, or delete." - These tiles are *previews only* — not the actual courses that will be seeded. They communicate "you're not landing in an empty room." **Continue**: calls a new `POST /onboarding/select-theme` with `{ theme }`, then advances to step 3. **Back**: returns to step 1 without losing org data (org is already persisted; back is just a step-state change). ### Step 3: User Metadata (unchanged in shape, behavior change on submit) - `goal` (radio) - `source` (radio) - `locale` (select) On submit: existing `updateUserOnboarding` runs, **then** the route invokes `seedDemoCoursesForGoal(orgId, profileId, goal)` synchronously (with a short timeout — see FR-4) before returning. Response includes `seededCourseIds` so the frontend can surface them in the post-redirect view. ### Post-Onboarding - Redirect to `/org/{siteName}/courses` (drop the `?create=true` marker — it was unused and the page is no longer empty). - The existing `welcomePopup` modal still fires and now references the seeded courses ("We added a few sample courses to get you started — feel free to edit or delete them."). --- ## Functional Requirements ### FR-1 — Theme Persistence - `POST /onboarding/select-theme` accepts `{ theme: OrgLandingPageTheme }`, validates against `landingPageThemes` (8 values), and patches `organization.landingPage.theme` using a `jsonb_set` so other landing-page fields (hero, navItems, footer) remain untouched. - Endpoint requires `authMiddleware`. Caller must be the owner of the org tied to the current onboarding session. - Idempotent — re-submitting the same theme is a no-op success; re-submitting a different theme overwrites. ### FR-2 — Curated Demo Course Set - Define `DEMO_COURSES_BY_GOAL: Record` in `packages/db/src/utils/demo-courses/index.ts`. - Each `DemoCourseSpec` includes: `title`, `description`, `overview` (markdown), `type` (`SELF_PACED` | `LIVE_CLASS` | `COMPLIANCE` | `PUBLIC`), `bannerImage` (URL to a static asset), optional `certificate` config, and a `sections: Array<{ title, lessons: Array<{ title, note, videos? }> }>` tree. - Lesson content: title + ~150-word markdown `note` + optional public YouTube embed reference. No file uploads required. - Target size: 2 courses per goal, 3 sections per course, 3 lessons per section — enough to look real, cheap to seed. - Mapping (initial): - `sell-online` → "Launch Your First Paid Course" (`SELF_PACED`), "Marketing Funnels for Course Creators" (`SELF_PACED`) - `teach-online` → "Designing Engaging Live Cohorts" (`LIVE_CLASS`), "Active Learning Toolkit" (`SELF_PACED`) - `employees` → "Workplace Compliance 101" (`COMPLIANCE`, with certificate), "Manager Essentials" (`SELF_PACED`) - `customers` → "Customer Onboarding Bootcamp" (`SELF_PACED`), "Product Mastery" (`PUBLIC`) - `expanding-platform` → one of each `type` (broadest exposure) ### FR-3 — Demo Course Seeding - New service `seedDemoCoursesForGoal(orgId, profileId, goal)` in `apps/api/src/services/onboarding/seed-demo-courses.ts`. - Internally calls a new query helper `createDemoCoursesForOrg(orgId, ownerProfileId, specs)` in `packages/db/src/queries/onboarding/demo-courses.ts` that runs **inside a single transaction**, mirroring the insert shape used by `createCourse` (`apps/api/src/services/course/course.ts:181`) — group + course + group-member + default section + welcome newsfeed — extended for the additional sections/lessons. - Seeded courses are created with `isPublished: false` and `isTemplate: false` so they appear as drafts the owner can edit/publish/delete. - **Idempotency**: before seeding, the service counts the org's non-template courses; if `>= 1`, it skips. This guards against double-submit of `/update-metadata`. ### FR-4 — Seed Failure Doesn't Block Redirect - The `/update-metadata` handler awaits `seedDemoCoursesForGoal` with a 5-second timeout. - On timeout or thrown error: log structured warning (`onboarding.seed_demo_courses.failed` with `orgId`, `goal`, `error`), return `{ success: true, data: { ...metadata, seededCourseIds: [] } }` so the user is not stranded. A background retry is **not** in scope for v1. ### FR-5 — Theme Picker UI - New `apps/dashboard/src/lib/features/onboarding/components/theme-picker.svelte`. - Renders the 8-theme grid + the 4-tile preview block. - Theme list is sourced from `landingPageThemes` in `apps/dashboard/src/lib/features/org/utils/landing-page.ts:22` (single source of truth). - Theme labels are translation keys: `onboarding.theme.{themeId}`. - Preview tiles reuse the existing course-card primitive (the one rendered at `/courses` — verify the component name during implementation; if multiple exist, pick the one used in the published course list). ### FR-6 — Step Constants & Progress Bar - Update `ONBOARDING_STEPS` to `{ ORG_SETUP: 1, THEME_PICKER: 2, USER_METADATA: 3 }`. - The existing progress bar (`apps/dashboard/src/routes/(auth)/onboarding/+page.svelte:24`) uses `Object.keys(ONBOARDING_STEPS).length`, so it auto-scales. - Back button is enabled on steps 2 and 3 (currently only enabled when `step > ORG_SETUP`). ### FR-7 — Validation - Add `ZOnboardingSelectTheme` in `packages/utils/src/validation/onboarding/`: ```ts export const ZOnboardingSelectTheme = z.object({ theme: z.enum(LANDING_PAGE_THEMES) }); ``` - `LANDING_PAGE_THEMES` must be a single shared literal exported once (either from `@cio/ui/custom/org-landing-page/types` or `@cio/utils`) and reused by both the validator and the frontend `landingPageThemes` array. ### FR-8 — Frontend Type Hygiene - Per CLAUDE.md frontend conventions: - All request/response types live in `apps/dashboard/src/lib/features/onboarding/utils/types.ts`, inferred from the API (`typeof classroomio.onboarding['select-theme'].$post`). - **No** type definitions in `.svelte.ts` files. - All copy lives in `apps/dashboard/src/lib/utils/translations/en.json` under `onboarding.theme.*`. ### FR-9 — Observability - Emit funnel events (whatever analytics layer the dashboard currently uses — verify before instrumenting): - `onboarding.theme_picker_viewed` - `onboarding.theme_selected` (with `theme` property) - `onboarding.demo_courses_seeded` (with `goal`, `count`, `latency_ms`) - `onboarding.demo_courses_seed_failed` (with `goal`, `error`) - These are additive; do not remove existing onboarding analytics. ### FR-10 — Self-Host Safety - The demo-course seed payload is a static constant in `@cio/db`. Self-hosted instances get the same behavior with no external dependencies (no LLM calls, no image hosting beyond static assets bundled with the dashboard). - Default theme `'minimal'` continues to be applied at org-create time, so users who skip step 2 (e.g. close the tab) still end up with a working landing page. --- ## Proposed API + Service Design ### Validation — `packages/utils/src/validation/onboarding/` - `ZOnboardingSelectTheme` — see FR-7. - `ZOnboardingUpdateMetadata` — **no shape change**; behavior change is server-side only. ### Routes — `apps/api/src/routes/onboarding/onboarding.ts` - `POST /onboarding/select-theme` *(new)* — auth + zValidator(ZOnboardingSelectTheme); delegates to `selectOnboardingTheme(profileId, orgId, theme)`. - `POST /onboarding/update-metadata` *(extended)* — after `updateUserOnboarding`, awaits `seedDemoCoursesForGoal(orgId, profileId, goal)` with timeout/log-on-fail behavior in FR-4. Returns `{ success: true, data: { ...existing, seededCourseIds } }`. ### Services — `apps/api/src/services/onboarding/` - `select-theme.ts` — `selectOnboardingTheme(profileId, orgId, theme)`. Authorizes profile is owner; calls query helper. - `seed-demo-courses.ts` — `seedDemoCoursesForGoal(orgId, profileId, goal)`. Idempotency check + delegate to query helper. ### Queries — `packages/db/src/queries/` - `organization/landing-page.ts` — `updateOrgLandingPageTheme(orgId, theme)` using `jsonb_set`. - `onboarding/demo-courses.ts` — `createDemoCoursesForOrg(orgId, profileId, specs)` running in a single transaction. ### Data — `packages/db/src/utils/demo-courses/index.ts` - Static export `DEMO_COURSES_BY_GOAL` + types. Lives in `@cio/db` so both API services and (future) self-host migration tools can import it. ### Dashboard — `apps/dashboard/src/lib/features/onboarding/` - `utils/constants.ts` — add `THEME_PICKER` step. - `utils/types.ts` — extend `OnboardingField` with `theme?: OrgLandingPageTheme`; add request type for the new endpoint. - `api/onboarding.svelte.ts` — add `submitTheme(theme)` method; update step dispatcher; advance to `USER_METADATA` on success. - `components/theme-picker.svelte` *(new)* — the new step UI. - `routes/(auth)/onboarding/+page.svelte` — extend the if/else block to a 3-way switch; wire back-button to step down through all 3 states. --- ## Data Model Changes **None at schema level.** Both new behaviors reuse existing tables: - Theme selection writes to the existing `organization.landingPage` jsonb column. - Demo courses use the existing `course`, `courseSection`, `lesson` tables (no new columns). No migration is required. (Aligns with the user's standing rule: schema edits stop at a passing `@cio/db` build; migrations are handled outside this workflow.) --- ## Translation Keys (new) Under `onboarding.theme.*` in `apps/dashboard/src/lib/utils/translations/en.json`: - `heading` — "Pick a look for your school" - `subheading` — "You can change this anytime in settings." - `demo_heading` — "Your workspace will come pre-loaded" - `demo_subheading` — "We'll add a few sample courses you can edit, publish, or delete." - `minimal`, `bold`, `classic`, `saas`, `tech`, `studio`, `corporate`, `terminal` — display labels. --- ## UX Quality Bar Per the user's standing UI rules: - Use `@cio/ui/base/card` for theme tiles, with `ui:ring-primary` for selected state (color tokens prefixed `ui:` for theme resolution). - Use the same course-card primitive `/courses` uses for the preview tiles — do not invent a new card. If a suitable component does not exist, extract one rather than copy-pasting markup. - No hardcoded user-facing strings. - No native `` / `