diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 67980ed2..7af8a261 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -86,7 +86,25 @@ export const app = new Hono() }) ) .use('/api/auth/sign-up/*', signupGuard) - .on(['POST', 'GET'], '/api/auth/*', (c) => auth.handler(c.req.raw)) + .on(['POST', 'GET'], '/api/auth/*', (c) => { + // Behind the Cloudflare Worker proxy the raw Request URL points at the + // upstream `.onrender.com` host. Better Auth (and the oauth-proxy plugin) + // need the original tenant/admin host to construct correct OAuth callback + // URLs and to decide whether to engage the proxy flow. Rebuild from + // X-Forwarded-Host / X-Forwarded-Proto when present. + let request = c.req.raw; + const fwdHost = c.req.header('x-forwarded-host'); + const fwdProto = c.req.header('x-forwarded-proto'); + if (fwdHost) { + const url = new URL(request.url); + url.host = fwdHost; + if (fwdProto === 'https' || fwdProto === 'http') { + url.protocol = `${fwdProto}:`; + } + request = new Request(url, request); + } + return auth.handler(request); + }) .get('/session', async (c) => { const session = c.get('session'); const user = c.get('user'); diff --git a/apps/api/src/services/widget-payload.ts b/apps/api/src/services/widget-payload.ts index 90359627..68cf82dc 100644 --- a/apps/api/src/services/widget-payload.ts +++ b/apps/api/src/services/widget-payload.ts @@ -3,6 +3,7 @@ import { getActiveOrganizationPlan, getOrganizationById } from '@cio/db/queries/ import { getCourseTagsByCourseIdsForOrganization, getTagGroupsWithTags } from '@cio/db/queries/tag'; import { listWidgetCourses } from '@cio/db/queries/widget'; import { PLAN } from '@cio/utils/plans'; +import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { buildWidgetPayload as buildWidgetPayloadShared, normalizeWidgetConfig, @@ -148,7 +149,7 @@ export function getCourseBaseUrl(siteName: string, customDomain?: string | null) return `https://${customDomain}`; } - return siteName ? `https://${siteName}.classroomio.com` : DEFAULT_WIDGET_APP_BASE_URL; + return siteName ? `https://${siteName}.${TENANT_ROOT_DOMAIN}` : DEFAULT_WIDGET_APP_BASE_URL; } export function getCourseWidgetScriptUrl(): string { diff --git a/apps/dashboard/src/lib/features/org/components/add-org-modal/add-org-modal.svelte b/apps/dashboard/src/lib/features/org/components/add-org-modal/add-org-modal.svelte index de68c6bd..1b068d7f 100644 --- a/apps/dashboard/src/lib/features/org/components/add-org-modal/add-org-modal.svelte +++ b/apps/dashboard/src/lib/features/org/components/add-org-modal/add-org-modal.svelte @@ -10,6 +10,7 @@ import { newOrgModal } from '../new-org-modal-store'; import { t } from '$lib/utils/functions/translations'; import { orgApi } from '$features/org/api/org.svelte'; + import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; let orgName = $state(''); let siteName = $derived(generateSitename(orgName)); @@ -52,7 +53,7 @@ {$t('add_org.org_sitename')} - + {#if orgApi.errors.siteName || orgApi.errors.general} {orgApi.errors.siteName || orgApi.errors.general} {/if} diff --git a/apps/dashboard/src/lib/features/settings/pages/domains.svelte b/apps/dashboard/src/lib/features/settings/pages/domains.svelte index 09d379e8..03729c07 100644 --- a/apps/dashboard/src/lib/features/settings/pages/domains.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/domains.svelte @@ -16,6 +16,7 @@ import { copyToClipboard } from '$lib/utils/functions/formatYoutubeVideo'; import { updateOrgSiteNameValidation } from '$lib/utils/functions/validator'; import { sanitizeDomain, sendDomainRequest } from '$lib/utils/functions/domain'; + import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { Badge } from '@cio/ui/base/badge'; import { Button } from '@cio/ui/base/button'; @@ -164,7 +165,7 @@ return; } - if (sanitizedDomain.includes('classroomio.com')) { + if (sanitizedDomain.includes(BRAND_ROOT_DOMAIN) || sanitizedDomain.includes(TENANT_ROOT_DOMAIN)) { errors.customDomain = $t('components.settings.domains.custom_domain_not_classroomio'); return; } @@ -284,7 +285,7 @@ onchange={(e) => (siteName = (e.target as HTMLInputElement)?.value ?? '')} placeholder="myschool" prefix="https://" - suffix=".classroomio.com" + suffix=".{TENANT_ROOT_DOMAIN}" /> {#if errors.siteName} {errors.siteName} diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte index 7001c556..09024432 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/app-logo.svelte @@ -4,8 +4,13 @@ import { currentOrg } from '$lib/utils/store/org'; import { Badge } from '@cio/ui/base/badge'; import { PLAN_NAMES, PLAN } from '@cio/utils/plans'; + import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; const plan = $derived($currentOrg.plans?.[0]?.planName || PLAN.BASIC); + const utmSource = $derived( + $currentOrg.customDomain || + ($currentOrg.siteName ? `${$currentOrg.siteName}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN) + ); @@ -16,7 +21,7 @@ > {#snippet child({ props })} import { Badge } from '@cio/ui/base/badge'; + import { TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
- yourorg.classroomio.com + yourorg.{TENANT_ROOT_DOMAIN}

Your Student Dashboard

diff --git a/apps/website/src/routes/(redirects)/privacy/+layout.svelte b/apps/website/src/routes/(redirects)/privacy/+layout.svelte index cf4f1aa9..0424d060 100644 --- a/apps/website/src/routes/(redirects)/privacy/+layout.svelte +++ b/apps/website/src/routes/(redirects)/privacy/+layout.svelte @@ -10,7 +10,7 @@ let { children } = $props(); - +
- +
-
+

{tool.title}

{tool.subText} diff --git a/packages/db/src/auth.ts b/packages/db/src/auth.ts index e6e1e21b..ca4b1a63 100644 --- a/packages/db/src/auth.ts +++ b/packages/db/src/auth.ts @@ -12,6 +12,7 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { config as emailAndPassword } from './auth/email-password'; import { getUserOrgRolesMap } from './queries/organization/organization'; import { loginLink } from './auth/plugins/login-link'; +import { oAuthProxy } from 'better-auth/plugins/oauth-proxy'; import { resolveTrustedBrowserOrigin } from './utils'; import { sso } from '@better-auth/sso'; import { syncUserWithProfile } from './auth/hooks/sync-user'; @@ -77,9 +78,12 @@ export const auth: ReturnType = betterAuth({ databaseHooks: { user: { create: { - after: async (user) => { - console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id }); - await createProfileHook(user); + after: async (user, ctx) => { + console.log('[auth] databaseHooks.user.create.after: running', { userId: user.id, ctx }); + const request = + (ctx as { context?: { request?: Request }; request?: Request } | undefined)?.context?.request ?? + (ctx as { request?: Request } | undefined)?.request; + await createProfileHook(user, request); } }, update: { @@ -109,6 +113,14 @@ export const auth: ReturnType = betterAuth({ // OIDC providers are registered dynamically per organization // via the admin API (auth.api.registerSSOProvider) }), + // Lets OAuth/SSO callbacks land on the canonical production URL + // (api.classroomio.com) while still completing the flow on the tenant + // host the user signed in from (.classroomio.school or a BYOD + // domain). Without this, the state cookie set on the tenant host isn't + // sent to the api host on the Google redirect → state_security_mismatch. + oAuthProxy({ + productionURL: CONSTANTS.BASE_URL + }), loginLink(), tokenExchange(), // Attaches the user's org memberships ({ [orgId]: roleId }) to the session diff --git a/packages/db/src/auth/hooks/create-profile.ts b/packages/db/src/auth/hooks/create-profile.ts index f28234ce..827f9218 100644 --- a/packages/db/src/auth/hooks/create-profile.ts +++ b/packages/db/src/auth/hooks/create-profile.ts @@ -6,6 +6,7 @@ import { User } from 'better-auth'; import { db } from '@db/drizzle'; import { getOrganizationCount } from '@db/queries/organization'; import { ssoProvisioningHook } from './sso-provisioning'; +import { tenantProvisioningHook } from './tenant-provisioning'; /** * True if the user has any non-credential account (OAuth/SSO). Those providers verify email. @@ -36,8 +37,8 @@ async function isSelfHostedFirstSignup(): Promise { * * Self-hosted first signup: auto-verify email (no orgs exist yet, this user will create the only org). */ -export const createProfileHook = async (user: User) => { - console.log('[auth] createProfileHook: running', { userId: user.id }); +export const createProfileHook = async (user: User, request?: Request) => { + console.log('[auth] createProfileHook: running', { userId: user.id, request }); const existingProfile = await db.select().from(schema.profile).where(eq(schema.profile.id, user.id)).limit(1); console.debug('existingProfile', existingProfile); @@ -81,8 +82,12 @@ export const createProfileHook = async (user: User) => { }); console.debug('newProfile', newProfile); - // Run SSO provisioning hook for JIT org membership + // Run SSO provisioning hook for JIT org membership (email-domain match) await ssoProvisioningHook(user); + + // Run tenant provisioning hook for signups on a tenant subdomain + // (signupGuard has already enforced disableSignup / inviteOnly) + await tenantProvisioningHook(user, request); } catch (error) { console.error('Error creating profile for user:', error); } diff --git a/packages/db/src/auth/hooks/tenant-provisioning.ts b/packages/db/src/auth/hooks/tenant-provisioning.ts new file mode 100644 index 00000000..0e993bb4 --- /dev/null +++ b/packages/db/src/auth/hooks/tenant-provisioning.ts @@ -0,0 +1,87 @@ +import { User } from 'better-auth'; +import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN, blockedSubdomain } from '@cio/utils/constants'; +import { getOrganizationByCustomDomain, getOrganizationBySiteName } from '@db/queries/organization'; + +import { ensureOrgMembership } from './sso-provisioning'; + +/** + * Subdomains under the brand/tenant root that are NOT a tenant org. + * Reuses the dashboard's `blockedSubdomain` list so the two stay in sync. + */ +const RESERVED_SUBDOMAINS = new Set(blockedSubdomain); + +/** + * Resolve the org a signup happened on, given the inbound request's host. + * + * Resolution order: + * 1. Subdomain under TENANT_ROOT_DOMAIN (e.g. ciodevs.classroomio.school) → siteName lookup + * 2. Subdomain under BRAND_ROOT_DOMAIN (e.g. ciodevs.classroomio.com) → siteName lookup + * 3. Anything else (e.g. learn.acme.com) → verified-custom-domain lookup + * + * Returns null for the apex, reserved subdomains (app/api/www/…), and unverified custom domains. + */ +async function resolveOrgIdFromHost(host: string): Promise { + const hostname = host.split(':')[0]?.toLowerCase(); + if (!hostname) return null; + + const tenantSuffix = `.${TENANT_ROOT_DOMAIN}`; + const brandSuffix = `.${BRAND_ROOT_DOMAIN}`; + + if (hostname.endsWith(tenantSuffix) || hostname.endsWith(brandSuffix)) { + const suffix = hostname.endsWith(tenantSuffix) ? tenantSuffix : brandSuffix; + const subdomain = hostname.slice(0, -suffix.length); + + // Apex (no subdomain) or multi-level subdomain we don't recognise + if (!subdomain || subdomain.includes('.')) return null; + if (RESERVED_SUBDOMAINS.has(subdomain)) return null; + + const org = await getOrganizationBySiteName(subdomain); + return org?.id ?? null; + } + + const org = await getOrganizationByCustomDomain(hostname); + return org?.id ?? null; +} + +/** + * Auto-enroll a signup into the org they signed up on. + * + * Runs after `ssoProvisioningHook` so SSO-domain matches (which carry policy-driven + * role mapping) win. `ensureOrgMembership` is idempotent and invite-aware, so if SSO + * already inserted a row this is a no-op. + * + * The `signupGuard` middleware (apps/api/src/middlewares/signup-guard.ts) has already + * rejected `disableSignup` orgs and `inviteOnly` orgs without a valid invite by the + * time this runs — so any signup that reaches the hook is allowed to join. + * + * Org resolution order: + * 1. `cio-org-id` request header (set by the dashboard on every API call) + * 2. Host header: tenant subdomain, brand subdomain, or verified custom domain. + * Prefers `x-forwarded-host` (set by the Cloudflare Worker proxy) over the + * raw `Host`, which behind the proxy is the upstream service name and not + * the tenant host the user actually visited. + * + * TODO(oauth): OAuth/SSO signups lose both the header and the original host — + * the callback lands on `api.classroomio.com/api/auth/callback/`, so + * neither path here can resolve the tenant. To fix, encode the org id in + * Better Auth's OAuth `state` (or `additionalParams`) at signup initiation and + * pull it out of `request` here. Until then, OAuth signups on a tenant site + * will not be auto-enrolled and must be invited manually. + */ +export const tenantProvisioningHook = async (user: User, request?: Request) => { + if (!user.email || !request) return; + + const headerOrgId = request.headers.get('cio-org-id'); + if (headerOrgId) { + await ensureOrgMembership(user.id, user.email, headerOrgId); + return; + } + + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host'); + if (!host) return; + + const orgId = await resolveOrgIdFromHost(host); + if (!orgId) return; + + await ensureOrgMembership(user.id, user.email, orgId); +}; diff --git a/packages/db/src/queries/organization/organization.ts b/packages/db/src/queries/organization/organization.ts index b765c843..5753af69 100644 --- a/packages/db/src/queries/organization/organization.ts +++ b/packages/db/src/queries/organization/organization.ts @@ -197,6 +197,26 @@ export const getOrganizationBySiteName = async (siteName: string): Promise => { + const [organization] = await db + .select() + .from(schema.organization) + .where( + and( + eq(schema.organization.customDomain, customDomain.toLowerCase()), + eq(schema.organization.isCustomDomainVerified, true) + ) + ) + .limit(1); + + return organization || null; +}; + /** * Gets an organization by ID * @param id Organization ID diff --git a/packages/db/src/utils/custom-domain/trusted-origin.ts b/packages/db/src/utils/custom-domain/trusted-origin.ts index 5ed787e5..9894068d 100644 --- a/packages/db/src/utils/custom-domain/trusted-origin.ts +++ b/packages/db/src/utils/custom-domain/trusted-origin.ts @@ -1,6 +1,7 @@ +import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { getVerifiedCustomDomainHostnames } from '../../queries/organization/organization'; -const CLASSROOMIO_ROOT = 'classroomio.com'; +const FIRST_PARTY_ROOTS: readonly string[] = [BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN]; /** Lowercase hostnames with verified custom domains ( warmed at API boot + updated on domain routes ). */ const verifiedCustomDomainHostnames = new Set(); @@ -25,7 +26,7 @@ function originMatchesStaticEntry(origin: string, entry: string): boolean { function isClassroomioHost(hostname: string): boolean { const host = hostname.toLowerCase(); - return host === CLASSROOMIO_ROOT || host.endsWith(`.${CLASSROOMIO_ROOT}`); + return FIRST_PARTY_ROOTS.some((root) => host === root || host.endsWith(`.${root}`)); } export function trustCustomDomainHostname(hostname: string): void { diff --git a/packages/ui/src/custom/widget-layouts/utils.ts b/packages/ui/src/custom/widget-layouts/utils.ts index 09563d8d..4b0e09cf 100644 --- a/packages/ui/src/custom/widget-layouts/utils.ts +++ b/packages/ui/src/custom/widget-layouts/utils.ts @@ -1,3 +1,4 @@ +import { BRAND_ROOT_DOMAIN, TENANT_ROOT_DOMAIN } from '@cio/utils/constants'; import { getWidgetThemeSerifStack, type TWidgetConfig, @@ -62,7 +63,7 @@ export function formatLessonsLine(course: TWidgetPayloadCourse): string { return parts.join(' · '); } -/** Hostname used in UTM (custom domain or `{siteName}.classroomio.com`). */ +/** Hostname used in UTM (custom domain or `{siteName}.${TENANT_ROOT_DOMAIN}`). */ export function getOrgDomainForUtm(organization: TWidgetPayload['organization']): string { const custom = organization.customDomain?.trim(); if (custom) { @@ -76,7 +77,7 @@ export function getOrgDomainForUtm(organization: TWidgetPayload['organization']) return custom.replace(/^\/+/, '').split('/')[0] || organization.siteName; } const site = organization.siteName.trim(); - return site ? `${site}.classroomio.com` : 'classroomio.com'; + return site ? `${site}.${TENANT_ROOT_DOMAIN}` : BRAND_ROOT_DOMAIN; } /** Marketing homepage link with widget + org attribution for analytics. */ @@ -89,5 +90,5 @@ export function buildPoweredByMarketingUrl(payload: TWidgetPayload): string { utm_content: payload.widgetId, utm_term: orgDomain }); - return `https://classroomio.com/?${params.toString()}`; + return `https://${BRAND_ROOT_DOMAIN}/?${params.toString()}`; } diff --git a/packages/utils/src/constants/domains.ts b/packages/utils/src/constants/domains.ts new file mode 100644 index 00000000..e3a6e3f9 --- /dev/null +++ b/packages/utils/src/constants/domains.ts @@ -0,0 +1,9 @@ +/** + * The apex that hosts every free-tier tenant site as `.`. + * The `apps/cio-tenant-router` Cloudflare Worker terminates traffic for this zone and forwards + * to the dashboard service. Marketing apex (classroomio.com) is intentionally separate. + */ +export const TENANT_ROOT_DOMAIN = 'classroomio.school'; + +/** The marketing / admin / api zone. */ +export const BRAND_ROOT_DOMAIN = 'classroomio.com'; diff --git a/packages/utils/src/constants/index.ts b/packages/utils/src/constants/index.ts index 7cd6241f..9ee97bfc 100644 --- a/packages/utils/src/constants/index.ts +++ b/packages/utils/src/constants/index.ts @@ -1,4 +1,5 @@ export * from './roles'; export * from './org'; export * from './content'; +export * from './domains'; export * from './error-codes'; diff --git a/prd/onboarding-platform-showcase/README.md b/prd/onboarding-platform-showcase/README.md new file mode 100644 index 00000000..fef63b83 --- /dev/null +++ b/prd/onboarding-platform-showcase/README.md @@ -0,0 +1,298 @@ +# 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 `` / `