chore: ai, public course and other ui improvements

This commit is contained in:
Rotimi Best
2026-05-09 06:05:03 +01:00
parent 5717dd8004
commit c31ad63955
318 changed files with 20668 additions and 4788 deletions
+243 -430
View File
@@ -1,488 +1,301 @@
# Public Courses PRD
## Purpose
Enable course creators to publish "Public Courses" - a new course type where lesson content is fully accessible without signup, while interactive features (quizzes, exercises, progress tracking) require authentication. This creates a low-friction entry point for learners and a lead generation channel for organizations.
## Problem Statement
Currently, all courses require signup before accessing any content. This creates friction:
- Learners abandon at signup gate before seeing course value
- Organizations can't use courses as top-of-funnel content marketing
- No way to offer "try before you buy" experiences
- SEO discoverability is limited (landing pages only, not deep content)
Enable course creators to publish **Public Courses** — a new course type where content is fully accessible without signup. Public courses treat every visitor as a guest: there is no "sign in to unlock" flow, no progress tracking, no submissions sent to the server. The goal is a low-friction, SEO-indexable surface that creators can use as top-of-funnel content marketing.
## Product Goals
1. Create a third course type: **Public Course** (alongside Live Class, Self Paced)
2. Allow anonymous browsing of full lesson content
3. Gate interactive features behind authentication (soft nudge, not hard wall)
4. Drive conversions through value demonstration, not forced signup
5. Maintain clear UX distinction between guest and authenticated modes
1. Add a third course type: **Public** (alongside Live Class and Self Paced; Compliance stays unchanged).
2. Allow anonymous browsing of a public course's full tree (sections, lessons, exercises) with clean readable URLs.
3. Keep the implementation deliberately small: no auth gating on public courses, no server-side submissions, no analytics, no donations, no live viewer counts.
4. Give creators a single customizable **Callout** to drive viewers somewhere (main site, sign-up, paid course, etc.).
5. Design the lesson surface as a focused, distraction-free reading/watching experience.
## Non-Goals (v1)
- Partial lesson locking (first X lessons free)
- Per-lesson public/private toggles
- Anonymous quiz attempts with delayed signup
Everything below is explicitly out of scope for this PRD:
- Guest vs authenticated distinction on public courses (everyone is treated the same)
- Progress tracking (server, database, or localStorage)
- Server-side exercise submissions and scoring
- Guest-to-account conversion / progress merge
- Analytics, guest events, conversion funnels
- Live viewer counts / "people learning now"
- Donations / Stripe payment links on public courses
- Public-courses directory or landing-page tab
- Feature flag / phased rollout gating
- Downloadable asset gating
- Discussion / community for public courses
- Per-lesson public/private toggle beyond the existing lock state
- Public course certificates
- Revenue sharing for public courses
## User Roles & Permissions
## User Roles
### Anonymous/Guest User
- View all lesson content (video, text, embeds)
- Navigate between lessons
- See course outline/structure
- **Cannot:** Take quizzes, submit exercises, track progress, bookmark
Public courses have **one** viewer role: everyone. Authentication state does not change what a viewer can do on a public course — a logged-in user visiting a public course is treated identically to an anonymous visitor.
### Authenticated User
- All guest capabilities PLUS:
- Take quizzes and exercises
- Track progress (lessons completed, quiz scores)
- Bookmark/save courses
- Participate in discussions (if enabled)
### Course Creator/Admin
- Choose course type at creation: Live Class | Self Paced | **Public Course**
- Convert existing courses to Public (with warning about implications)
- View analytics: guest views vs authenticated engagement
### Course Creator / Admin
- Selects course type at creation: **Live Class | Self Paced | Public** (Compliance remains a separate flow).
- Can convert an existing course between types (behavior of conversion is not changed by this PRD).
- When the course type is `PUBLIC`:
- The question-type picker is filtered to **only auto-gradable** question types.
- Per-item slugs (lesson / exercise) are editable; otherwise they are auto-generated from the title.
- A **Callout** settings panel is available (title, description, button label, button URL).
## Functional Requirements
### 1. Course Type Selection
#### At Course Creation
`COURSE_TYPE` enum today is `['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE']` with default `'LIVE_CLASS'`. This PRD changes it to:
```
Create New Course
[○] Live Class - Scheduled sessions with instructor
[○] Self Paced - Learner progresses at own pace
[●] Public Course - Open access, signup required for quizzes
[Continue] → Course Setup Wizard
enum COURSE_TYPE = ['SELF_PACED', 'LIVE_CLASS', 'COMPLIANCE', 'PUBLIC']
default = 'SELF_PACED'
```
#### Course Type Indicator
- Public courses show "Public" badge on cards (landing page, dashboard)
- Icon: `Globe` or `Unlock` to signify openness
- Color: Distinct from Live (red) and Self Paced (blue) - suggest green or teal
Changing the default from `LIVE_CLASS` to `SELF_PACED` is intentional.
### 2. Anonymous Content Access
A public course must still have `isPublished = true` to be reachable at its public URL. An unpublished public course behaves like any other unpublished course (not accessible).
#### Lesson View (Guest Mode)
### 2. Only Auto-Gradable Question Types in Public Courses
Public courses are graded **entirely on the client**. The question-type registry already exposes an `autoGradable: boolean` flag on every question type.
- When the course type is `PUBLIC`, the question-type picker only shows question types with `autoGradable === true`.
- The API rejects attempts to attach a non-auto-gradable question to an exercise belonging to a public course.
- If a course is converted **into** `PUBLIC` and it already contains non-auto-gradable questions, the conversion is blocked with a clear error listing the offending questions. (No silent data loss.)
### 3. Lock State (reuse existing)
Public courses reuse the existing `lesson.isUnlocked` flag (and the existing `exercise.isUnlocked` flag) as the gate for "free vs gated" content.
- `isUnlocked = true` → content renders normally.
- `isUnlocked = false` → the content area is replaced by the course's **Callout** (see §5). The sidebar row for a locked item still renders, with a lock icon next to the number.
Since every visitor is treated as a guest, there is no "sign in to unlock" path. A locked item is a call-to-action surface for the creator's callout.
### 4. Public URLs and Slugs
#### Routes
```
┌─────────────────────────────────────────────────────────┐
│ Course: Introduction to Python [Sign In] │
│ │
│ ┌──────────────┐ ┌─────────────────────────────────┐ │
│ │ Lesson 3/12 │ │ Video Player / Lesson Content │ │
│ │ │ │ │ │
│ │ ◉ Lesson 1 │ │ [Full content accessible] │ │
│ │ ◉ Lesson 2 │ │ │ │
│ │ ◍ Lesson 3 │ │ │ │
│ │ ○ Lesson 4 │ │ │ │
│ │ ... │ │ │ │
│ └──────────────┘ └─────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────┐ │
│ │ 💡 Sign in to: │ │
│ │ • Track your progress │ │
│ │ • Take practice quizzes │ │
│ │ • Earn a certificate │ │
│ │ │ │
│ │ [Create Free Account] │ │
│ └───────────────────────────────┘ │
└─────────────────────────────────────────────────────────┘
(org-site)/course/[courseSlug] # course overview: tree of sections/lessons/exercises
(org-site)/course/[courseSlug]/lesson/[itemSlug] # a single lesson or exercise page
```
#### Access Rules
| Feature | Guest | Authenticated |
|---------|-------|---------------|
| View lesson content | ✅ | ✅ |
| Navigate lessons | ✅ | ✅ |
| See course outline | ✅ | ✅ |
| View quiz content | ✅ (read-only) | ✅ |
| Submit quiz answers | ❌ | ✅ |
| Submit exercises | ❌ | ✅ |
| Progress tracking | ❌ (localStorage only) | ✅ |
| Bookmark/continue | ❌ | ✅ |
| Discussion forum | Read-only | Read + Write |
The URL segment is always `/lesson/` whether the item is a lesson or an exercise. The `itemSlug` resolves to one or the other.
### 3. Conversion Nudges (Soft Gates)
#### Slug storage
- Add `slug VARCHAR` to `lesson` and `exercise` tables.
- Slugs are **unique per course** across both `lesson` and `exercise` (they share the same URL segment).
- On lesson / exercise create, slug is auto-generated from the title (kebab-case, collision-resolved by appending `-2`, `-3`, ...).
- On title rename, slug is **not** automatically updated (URLs are stable).
- When the course type is `PUBLIC`, creators can edit the slug from the lesson / exercise settings UI.
- When the course type is not `PUBLIC`, slugs are still generated and stored (so converting a course to `PUBLIC` later works) but are not surfaced in the UI.
#### Contextual Prompts
1. **On Quiz View** (guest):
```
┌────────────────────────────────────────┐
│ 📋 Practice Quiz: Variables & Types │
│ │
│ [Question content visible] │
│ │
│ ┌────────────────────────────────────┐ │
│ │ 🚀 Ready to test your knowledge? │ │
│ │ │ │
│ │ Sign in to take this quiz and │ │
│ │ track your progress. │ │
│ │ │ │
│ │ [Sign In to Continue] │ │
│ └────────────────────────────────────┘ │
└────────────────────────────────────────┘
```
#### Slug format
- 1–80 chars, `[a-z0-9]` with single `-` separators, no leading/trailing `-`.
- Validated on both client and server with a shared Zod schema.
2. **After Lesson Completion** (guest):
```
Nice work completing this lesson! 🎉
[Sign in] to save your progress and pick up
where you left off next time.
[Continue as Guest] [Create Account]
```
### 5. Data Loading Strategy
3. **Progress Persistence Banner** (guest, after 2+ lessons):
```
⚠️ You're making great progress! Sign in to save
your place before you leave.
[Dismiss] [Save My Progress]
```
- `GET (org-site) /course/[courseSlug]` loads the **full tree** server-side: course metadata, sections, lessons, exercises, slugs, titles, order, and `isUnlocked`. No lesson bodies, no exercise question bodies.
- `GET (org-site) /course/[courseSlug]/lesson/[itemSlug]` loads the **content** of the single item server-side (video URL, lesson body, or exercise questions with answer keys). This render is SSR for SEO.
- The tree is not re-fetched when the client navigates between lesson URLs — it is passed down through the shared `+layout.server.ts`.
#### Nudge Strategy
- **Never block content** with modal on page load
- **Inline prompts** within context (quiz area, lesson end)
- **Dismissible** - guests can continue browsing
- **Frequency cap** - max 1 prompt per session per nudge type
### 6. Lesson Page Layout
### 4. Anonymous Progress (localStorage)
Reference designs in `prd/public-courses/design/`:
- `lesson-page-reference.png` — overall layout
- `lesson-page-full.png` — full composition (hero video + title + body)
- `sidebar-active-state.png` — active lesson state in the sidebar
Two-column, non-collapsible layout. No hamburger toggle, no accordion on the sidebar, no hide/show. Sidebar is always visible on desktop.
#### Left sidebar (navigation)
- Grouped by the course's **sections** (existing `courseSection` / lesson-section concept).
- Each section renders:
- A small, muted section heading (e.g. "AI Foundations"). Plain text — not a button, not collapsible.
- A vertical list of that section's lessons and exercises, in their existing `order`.
- Each row contains:
- A **square number badge** on the left with the per-section index (1, 2, 3, …). Numbering resets at the start of each section and is shared across lessons + exercises (they interleave by `order`).
- The item title to the right of the badge.
- If the item's `isUnlocked === false`, a lock icon replaces the number badge or sits next to it (final placement: TBD with design).
- The active item (matching the current `itemSlug`) renders with:
- **Primary color** (the app's `--primary` token, *not* the hard-coded orange in the screenshots) filling the number badge background, white number text.
- **Primary color** title text.
- A subtle primary-colored vertical accent bar on the far left edge (outside the sidebar content area, aligned with the row).
- Hover state (non-active rows):
- Number badge gets a slightly darker muted fill.
- Title text gets a subtle color shift (muted-foreground → foreground).
- Cursor is pointer.
- No icons on normal rows, no progress rings, no status dots. Keep it text-first.
#### Right content column
Vertical order, top to bottom:
1. **Video** (if the item has one) — full-width within the content column, rounded corners, single centered play affordance. Omitted entirely if no video (no empty placeholder).
2. **Section label** — small, muted text above the title (the name of the section this item belongs to).
3. **Item title** — large, prominent heading.
4. **Body** — prose / rich text for lessons, or the rendered question UI for exercises. Readable line length, generous line-height.
5. **Callout** (see §7) — rendered as an inline card at the bottom of the body, only if the course has a callout configured.
If the item is **locked** (`isUnlocked = false`), the body and video are replaced by the callout card rendered in a larger, centered variant; steps 1–4 above are skipped.
No top banner, no hero image above the video, no breadcrumb. Minimal page chrome.
#### Responsive
**Desktop (≥ `lg`)**
- Two columns: sidebar fixed width on the left, content column fluid on the right.
- Sidebar is always visible (not collapsible).
- No bottom navigation bar.
**Mobile / tablet (< `lg`)**
- The left sidebar is **hidden**.
- A **fixed bottom navigation bar** is pinned to the viewport. It has three regions:
- **Left**: previous-item button (arrow-left icon). Navigates to the previous item in the flattened, section-ordered sequence. Disabled visually at the first item.
- **Center**: large tap target. Shows the current item's position (e.g. the per-section number badge + the item title, or "3 / 12"). Tapping opens a bottom sheet.
- **Right**: next-item button (arrow-right icon). Disabled visually at the last item.
- The bottom sheet (built on `@cio/ui/base/drawer` — vaul-based) slides up from the bottom and contains the **full sidebar tree**: every section with its heading and every numbered row. The sheet supports the native drag-to-dismiss gesture ("swiper from bottom up"). The current item is visually active in the sheet. Tapping a row navigates and auto-closes the sheet.
- The content column takes the full viewport width minus the bottom bar's height. The page adds bottom padding equal to the bar's height so the callout / body never sits under the bar.
- The bottom bar uses `--primary` for the center button's accent (matching the sidebar active state) and solid surface background with a top hairline border.
**Shared**
- "Prev / next sequence" is the flat `ORDER BY sections.order, items.order` across all items (lessons + exercises interleaved). Locked items are part of the sequence — tapping prev/next into a locked item still navigates; the locked state is then rendered in the content column as described in §6.
- Keyboard shortcuts on desktop: `←` / `→` also move through this sequence (nice-to-have, not required for v1 acceptance).
#### Colors
All accent colors in the sidebar must resolve to the app's **primary** color token (`--primary`), not the hard-coded orange visible in the reference screenshots. The screenshots are from Cursor Learn and are included purely as a layout reference.
### 7. Creator Callout (per-course setting)
Each public course has an optional **Callout** configured by the creator:
#### What We Track (Guest)
```typescript
// localStorage key: `guest_progress_{courseId}`
{
courseId: "course_xxx",
lastVisitedLessonId: "lesson_123",
completedLessons: ["lesson_001", "lesson_002"],
totalTimeSpent: 1847, // seconds
firstVisit: "2026-02-19T10:00:00Z",
lastVisit: "2026-02-19T14:30:00Z"
}
type CourseCallout = {
title: string; // short, 1 line
description: string; // 1–3 lines
buttonLabel: string; // e.g. "Read more on my site"
buttonUrl: string; // absolute URL, validated
} | null;
```
#### Conversion to Authenticated
- On signup/login, prompt: "Continue course from where you left off?"
- Merge localStorage progress into authenticated account
- Clear localStorage after successful merge
- Stored as a nullable JSONB column on `course` (e.g. `callout`).
- Configured from the course settings page (only visible when course type is `PUBLIC`).
- Rendered on the public lesson page:
- As an inline card at the bottom of the lesson/exercise body (on every item).
- As the full replacement when an item is locked (larger centered variant).
- Opens `buttonUrl` in a new tab (`target="_blank" rel="noopener"`).
### 5. Landing Page Integration
When `callout` is `null`, no callout is rendered anywhere (lesson still reads cleanly without it; locked items show a plain "This lesson is locked" state).
#### New "Public Courses" Section
```
Our Courses
### 8. Client-Side Grading
[Live Classes] [Self Paced] [Public Courses]
For exercises on a public course:
- The server ships full question data including answer keys to the client.
- Grading happens entirely in the browser — correctness, explanations, and "you got X / Y correct" all computed locally.
- No submission is sent to the server.
- No attempt history is recorded.
- Refreshing the page resets the exercise state.
Explore free courses - no signup required to start learning!
This is an intentional trade-off: public courses are marketing surfaces, so answer keys are not considered sensitive.
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ [thumbnail] │ │ [thumbnail] │ │ [thumbnail] │
│ │ │ │ │ │
│ Python │ │ Web Design │ │ Excel │
│ Basics │ │ Fundamentals│ │ for Work │
│ │ │ │ │ │
│ 🌐 Public │ │ 🌐 Public │ │ 🌐 Public │
│ ⭐ 4.8 (234)│ │ ⭐ 4.6 (189)│ │ ⭐ 4.9 (512)│
│ │ │ │ │ │
│ [Start │ │ [Start │ │ [Start │
│ Learning] │ │ Learning] │ │ Learning] │
└─────────────┘ └─────────────┘ └─────────────┘
```
### 9. SEO
#### Course Card Badges
- **Live Class**: "📅 Live" badge (red)
- **Self Paced**: "📚 Self Paced" badge (blue)
- **Public Course**: "🌐 Public" badge (green/teal)
- Public course lesson URLs render server-side (SvelteKit SSR).
- Each lesson page sets:
- `<title>` = item title + course title
- `<meta name="description">` = course description or first ~150 chars of the item body
- `<link rel="canonical">` = the public URL
- `og:type=article`, `og:title`, `og:description`, `og:image` (course banner)
- Locked items still render meta tags but the body is replaced by the callout; the `<description>` falls back to the course description.
- No `robots` meta restrictions beyond what the org already sets globally.
### 6. URL Structure
## Data Model Changes
### `course` table
- `type` enum: add `'PUBLIC'`, change default to `'SELF_PACED'`.
- New column `callout JSONB NULL` (shape described in §7).
### `lesson` table
- New column `slug VARCHAR NULL`.
- Backfill existing rows with kebab-cased titles (collision-resolved per course).
- After backfill, `slug` becomes `NOT NULL` in a follow-up migration.
- Existing `public BOOLEAN` column is deprecated — not referenced in new code. Column is **not** dropped in this PRD (to keep the migration small).
### `exercise` table
- New column `slug VARCHAR NULL`, same treatment as `lesson.slug`.
### Slug uniqueness
- Enforced at the application layer: a slug is unique per-course across the union of `lesson.slug` and `exercise.slug`.
- A DB-level constraint is not added in v1 (would require a materialized view or a trigger across two tables); application code must check before insert/update.
## API Changes
### New / modified endpoints (high level)
```
# Public course lesson (accessible to all)
/course/{courseSlug}/lesson/{lessonSlug}
GET (public) /org-site/course/:courseSlug
→ Returns course metadata + tree (sections, lessons, exercises with slugs, titles, order, isUnlocked).
→ No auth required when course.type === 'PUBLIC' && course.isPublished === true.
# Same URL works for both guest and authenticated
# Server renders content based on auth state
# No separate /public/ prefix needed
GET (public) /org-site/course/:courseSlug/item/:itemSlug
→ Returns either a lesson content payload or an exercise payload (with answer keys).
→ Response is a single shape with a discriminator field (e.g. `kind: 'lesson' | 'exercise'`).
→ No auth required under the same conditions as above.
PATCH (authed) /courses/:courseId/lessons/:lessonId
→ Accepts `slug` field (validated against slug regex and per-course uniqueness).
PATCH (authed) /courses/:courseId/exercises/:exerciseId
→ Accepts `slug` field (same validation).
POST (authed) /courses/:courseId/exercises/:exerciseId/questions
PATCH (authed) /courses/:courseId/exercises/:exerciseId/questions/:questionId
→ Reject non-auto-gradable question types when the parent course is PUBLIC.
PATCH (authed) /courses/:courseId
→ Accepts `callout` object (nullable).
→ Accepts `type` (validates transition, including the "no non-auto-gradable questions exist" check when converting to PUBLIC).
```
### 7. SEO & Sharing
Per the repo's single-response-shape rule: the item endpoint returns one response shape with a `kind` discriminator; client narrows on `kind`.
#### Meta Tags for Public Courses
```html
<meta name="description" content="Free course: Learn Python basics. No signup required.">
<meta property="og:type" content="article">
<meta property="og:availability" content="public">
```
## UI Components Needed
#### Social Sharing Card
```
┌────────────────────────┐
│ │
│ [Course Thumbnail] │
│ │
├────────────────────────┤
│ Python Basics │
│ Free course - start │
│ learning instantly! │
│ │
│ 🌐 classroomio.com │
└────────────────────────┘
```
- `PublicCourseShell` (layout wrapper: sidebar + content)
- `PublicCourseSidebar` (the numbered, sectioned nav — reusable for any item count)
- `PublicCourseSidebarRow` (number badge + title + lock state)
- `PublicLessonView` (video + section label + title + body + callout)
- `PublicExerciseView` (exercise questions rendered for client-side grading)
- `PublicCourseCallout` (inline + locked variants)
- `CourseCalloutSettings` (creator settings panel, only visible when course is PUBLIC)
- `LessonSlugField` / `ExerciseSlugField` (editable slug inputs in item settings, only visible when course is PUBLIC)
## Technical Requirements
Existing components (`QuestionRenderer`, `QuestionList`, etc.) are reused for exercise rendering, configured with the client-grading mode.
### Data Model Changes
## Storybook-First Build
#### Course Table
```typescript
// Add to existing course schema
type CourseType = 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC';
The public course UI is built as a set of Storybook components and consumed by the `(org-site)` routes. Stories are the primary development + review surface — reviewers test desktop and mobile layouts in Storybook before the routes are wired up.
interface Course {
id: string;
name: string;
slug: string;
// ... existing fields
// NEW
type: CourseType; // default: 'SELF_PACED'
isPublic: boolean; // computed: type === 'PUBLIC'
// Analytics (NEW)
guestViews: number; // counter
authenticatedViews: number; // counter
conversionCount: number; // guests who signed up and enrolled
}
```
Required stories (at minimum):
- `PublicCourseShell` — desktop and mobile viewports, with and without callout.
- `PublicCourseSidebar` — default state, with a locked item, with many sections.
- `PublicCourseSidebarRow` — default, hover, active, locked variants as individual stories.
- `PublicCourseBottomNav` — first item (prev disabled), middle item, last item (next disabled).
- `PublicCourseMobileSheet` — closed and open states (open state is the default story for review).
- `PublicLessonView` — with video, without video, long body, short body.
- `PublicExerciseView` — unanswered, mid-answer, after-grade (client-side).
- `PublicCourseCallout` — inline and full (locked) variants, and "no callout configured" fallback.
- `PublicCoursePage` — composed story showing the full page; run with Storybook's viewport addon to verify desktop and mobile side-by-side.
#### Enrollment Handling
- **Guest**: No enrollment record, localStorage tracking only
- **Authenticated**: Auto-enrolled on first authenticated interaction (quiz attempt, etc.)
- No explicit "Enroll" button for public courses - just start learning
All stories must resolve colors through the app's theme tokens (`--primary`, etc.), not hard-coded values.
### API Changes
## Open Items (non-blocking)
#### New/Modified Endpoints
```typescript
// GET /courses/:slug/lessons/:lessonId
// Response changes based on auth:
// - Guest: lesson content + "requiresAuth" flags for interactive elements
// - Auth: lesson content + user's progress + full interactivity
// POST /courses/:slug/enroll (auto-triggered for public courses)
// Called implicitly when authenticated user first interacts
// GET /courses/public
// List public courses for landing page
// No auth required
// POST /analytics/guest-event
// Fire-and-forget guest activity tracking (privacy-safe)
```
### Permission Matrix
| Action | Guest | Authenticated | Admin |
|--------|-------|---------------|-------|
| View lesson content | ✅ | ✅ | ✅ |
| View quiz questions | ✅ (no answers) | ✅ | ✅ |
| Submit quiz | ❌ | ✅ | ✅ |
| Submit exercise | ❌ | ✅ | ✅ |
| See "correct answers" | ❌ | ✅ | ✅ |
| Post in discussion | ❌ | ✅ | ✅ |
| View progress stats | ❌ (local only) | ✅ | ✅ |
| Download resources | Configurable* | ✅ | ✅ |
\* Depends on `allowGuestDownloads` setting |
## Analytics & Success Metrics
### Key Metrics
1. **Guest to Auth Conversion Rate**: % of guests who sign up
2. **Guest Engagement**: Avg lessons viewed per guest session
3. **Time to Signup**: How many lessons before conversion
4. **Public Course Traffic**: % of total site traffic from public courses
5. **SEO Impact**: Organic search traffic to course pages
### Tracking Events
```typescript
// Guest events (anonymous, privacy-safe)
- public_course_viewed
- public_lesson_completed
- conversion_prompt_shown
- conversion_prompt_clicked
- guest_signup_initiated (from course context)
// Post-conversion
- guest_progress_merged
- public_course_enrolled (after signup)
```
## UX Considerations
### Clear Communication
- Banner on first visit: "You're browsing in guest mode. [Sign in] for full features."
- Persistent subtle indicator: "👤 Guest Mode" in header
- Tooltips on locked features explain why signup is needed
### Seamless Transition
- After signup, return to exact lesson/position
- Preserve scroll position
- Auto-merge localStorage progress
### No Dead Ends
- Every "locked" feature has clear path forward
- Alternative actions for guests (e.g., "Preview quiz questions" instead of "Take quiz")
## Future Enhancements (Post-v1)
1. **Course Previews**: Any course can have first lesson public
2. **Anonymous Quiz Attempts**: Try once, see score, signup to save
3. **Public Course Analytics Dashboard**: Creator insights on guest engagement
4. **Email Capture**: "Get notified when new public courses launch"
5. **Paywall Option**: Public → Paid conversion for premium tier
- Precise placement of the lock icon in a sidebar row (inside the number badge, replacing it, or beside it) — design review later.
- Center button content in the mobile bottom bar — "3 / 12" vs "Section • item title" vs both — finalized during Storybook review.
## Success Criteria
1. Guest users can view full course content without any signup friction
2. Conversion prompts appear contextually without being intrusive
3. Public courses appear on landing page with clear distinction
4. Post-signup experience seamlessly continues from guest session
5. No significant bounce rate increase on public course pages
6. Measurable conversion rate from guest to authenticated user
## Open Questions (Resolved)
1. ✅ **Downloadables** - Configurable per course (creator decides: public or gated)
2. ✅ **Live viewer count** - Yes, show "X people learning now" for social proof
3. ✅ **Donations** - Configurable Stripe link for "support this course"
4. ✅ **Revert to private** - Yes, creators can convert public → self-paced (with warning)
## Additional Course Settings for Public Courses
### Creator Configuration Options
When creating/editing a public course, creators can configure:
```typescript
interface PublicCourseSettings {
// Access control
allowGuestDownloads: boolean; // default: true
// Social proof
showLiveViewerCount: boolean; // default: true
// Monetization
acceptDonations: boolean; // default: false
donationLink?: string; // Stripe payment link
suggestedAmount?: number; // e.g., 5, 10, 25 USD
// Conversion
showConversionNudges: boolean; // default: true
nudgeFrequency: 'low' | 'medium' | 'high'; // default: 'medium'
}
```
#### UI: Public Course Settings Panel
```
Public Course Settings
─────────────────────────────
☑ Allow guests to download resources
[?] When checked, PDFs and files are downloadable without signup
☑ Show live viewer count
[?] Displays "X people learning now" on course page
☐ Accept donations via Stripe
Stripe Payment Link: [____________________]
Suggested Amount: $[10] USD
[?] Add a "Support this course" button
Conversion Nudges
(•) Low - After 3+ lessons and quiz views
( ) Medium - After 2+ lessons and at quizzes
( ) High - After every lesson and on all interactive elements
```
### Live Viewer Count Display
```
┌────────────────────────────────────────┐
│ Python Basics 🌐 Public │
│ │
│ 👥 24 people learning now │
│ ⭐ 4.8 (234 reviews) │
│ │
│ [💝 Support $10] [Start Learning →] │
└────────────────────────────────────────┘
```
**Implementation:**
- WebSocket or polling for real-time count
- Count unique sessions in last 15 minutes
- Fallback to "Popular" badge if count < 5
### Download Access Control
| Resource Type | Guest (allowed) | Guest (blocked) |
|--------------|-----------------|-----------------|
| Video lessons | ✅ Always | ✅ Always |
| Text content | ✅ Always | ✅ Always |
| PDF resources | ✅ Download | 🔒 Sign in to download |
| Code files | ✅ Download | 🔒 Sign in to download |
| Worksheets | ✅ Download | 🔒 Sign in to download |
### Reverting Public → Private
Creators can change course type, but with safeguards:
```
⚠️ Change Course Type
You are about to convert "Python Basics" from Public to Self Paced.
This will:
• Require login to access all content
• Preserve existing enrolled students' access
• Remove course from Public Courses listing
• Break any shared public links (will redirect to login)
[X] I understand this affects [1,247] guest learners
[Cancel] [Convert to Self Paced]
```
**Grace Period:**
- Existing guest sessions get 7-day grace period
- Show banner: "This course is now private. Sign up to continue."
- After 7 days, all guest access revoked
## Updated Data Model
### Course Table Additions
```typescript
interface Course {
// ... existing fields
type: 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC';
// Public course settings (nullable for non-public)
publicSettings?: {
allowGuestDownloads: boolean;
showLiveViewerCount: boolean;
acceptDonations: boolean;
donationLink?: string;
suggestedAmount?: number;
showConversionNudges: boolean;
nudgeFrequency: 'low' | 'medium' | 'high';
};
// Analytics
currentViewers: number; // real-time-ish count
totalGuestViews: number; // lifetime
donationTotal?: number; // if accepting donations
}
```
1. A creator can set a course's type to `PUBLIC` and the course becomes reachable at `(org-site)/course/[slug]` with no sign-in required.
2. The question-type picker hides non-auto-gradable types when the course type is `PUBLIC`.
3. Lesson and exercise slugs are auto-generated and editable (for public courses).
4. Locked items on a public course render the creator's callout in place of content; unlocked items render normally.
5. Exercises on public courses are graded entirely in the browser with no server submission.
6. The lesson page layout matches the reference designs: non-collapsible sidebar, per-section numbering, content column order (video → section label → title → body → callout).
7. All accent colors resolve to `--primary`, not a hard-coded orange.
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+199 -237
View File
@@ -1,40 +1,34 @@
# Public Courses Implementation Plan
## Scope Lock
1. **Course type**: Third option alongside Live Class and Self Paced
2. **Content access**: Full lesson content public, quizzes/exercises gated
3. **Conversion**: Soft nudges only, never hard modal on load
4. **Progress**: localStorage for guests, auto-merge on signup
5. **Landing page**: New "Public Courses" tab with distinct badge
6. **URL**: Same URL structure for guests and authenticated users
7. **SEO**: Public courses indexable with proper meta tags
1. **Course type**: add `PUBLIC` to `COURSE_TYPE` enum, change default to `SELF_PACED`.
2. **Everyone is a guest**: no auth-based branching on public courses.
3. **Content access**: entire tree + per-item content readable without auth when `course.type === 'PUBLIC'` and `course.isPublished === true`.
4. **Gating**: reuse existing `lesson.isUnlocked` / `exercise.isUnlocked`. Locked items show the creator callout in place of content.
5. **Grading**: exercises on public courses are graded entirely client-side. Answer keys ship to the client. No server submissions.
6. **Question types**: only `autoGradable` question types allowed in public courses. Enforced in UI picker, API writes, and type-conversion checks.
7. **URLs**: `(org-site)/course/[courseSlug]` (tree) and `(org-site)/course/[courseSlug]/lesson/[itemSlug]` (content). Single `/lesson/` segment for both lessons and exercises.
8. **Slugs**: new `slug` columns on `lesson` and `exercise`. Auto-generated from title, editable when course is `PUBLIC`. Unique per course across lesson + exercise.
9. **Callout**: new `callout` JSONB on `course` with `{ title, description, buttonLabel, buttonUrl }`. Optional.
10. **Out of scope**: analytics, donations, live viewer counts, feature flag, localStorage progress, directory tab, discussion, progress tracking of any kind.
## Delivery Phases
### Phase A: Data Model & Course Type (Days 1-2)
- Add `type` column to course table
- Update validation schemas
- Modify course creation wizard
### Phase A — Data model + validation (Days 1–2)
Schema changes, migrations, slug backfill, validation schemas.
### Phase B: Anonymous Content Access (Days 3-5)
- Make lesson endpoints work without auth for public courses
- Add permission checks for gated features
- Implement localStorage progress tracking
### Phase B — API: public read endpoints + slug + callout (Days 3–5)
Public-facing GET endpoints for the tree and item content; PATCH endpoints accept slug and callout; guard writes against non-auto-gradable question types.
### Phase C: Conversion UX (Days 6-8)
- Build nudge components (quiz gate, lesson complete, progress banner)
- Implement guest mode UI indicators
- Add signup flow with progress merge
### Phase C — Creator UI (Days 6–8)
Course type picker adds `PUBLIC`; question-type picker filters by `autoGradable` for public courses; slug field in lesson / exercise settings; callout settings panel.
### Phase D: Landing Page (Days 9-10)
- Add Public Courses tab to landing page
- Create course type badges
- Update course cards with type indicators
### Phase D — Public course UI in Storybook (Days 9–12)
**Storybook first.** Build every component with its stories in `packages/ui` + `packages/storybook` so desktop and mobile layouts can be reviewed in isolation before wiring up the routes. Includes: `PublicCourseShell`, `PublicCourseSidebar`, `PublicCourseSidebarRow`, `PublicCourseBottomNav`, `PublicCourseMobileSheet`, `PublicLessonView`, `PublicExerciseView`, `PublicCourseCallout`, plus a composed `PublicCoursePage` story.
### Phase E: Analytics & Polish (Days 11-12)
- Implement guest analytics tracking
- Add conversion funnel metrics
- Testing and edge cases
### Phase E — Wire routes + polish (Days 13–14)
Mount the storybook-ready components at `(org-site)/course/[slug]` and `(org-site)/course/[slug]/lesson/[itemSlug]`. SEO meta tags, canonical URLs, locked-state rendering, a11y pass, manual QA against the reference designs.
---
@@ -42,190 +36,164 @@
| ID | Area | Task | Key Files | Dependencies | Done When |
|----|------|------|-----------|--------------|-----------|
| PC-A1 | DB | Add `type` enum to course schema | `packages/db/src/schema.ts` | None | Migration runs, `type` column exists with default 'SELF_PACED' |
| PC-A2 | DB | Update course queries to include type | `packages/db/src/queries/course/course.ts` | PC-A1 | Queries return course type |
| PC-A3 | Utils | Add course type validation | `packages/utils/src/validation/course/course.ts` | PC-A1 | Zod schema accepts 'LIVE_CLASS' \| 'SELF_PACED' \| 'PUBLIC' |
| PC-A4 | API | Update course create/update routes | `apps/api/src/routes/course/course.ts` | PC-A3 | Can create course with type 'PUBLIC' |
| PC-B1 | API | Make lesson content endpoint public-safe | `apps/api/src/routes/lesson/lesson.ts` | PC-A2 | Guests can GET lesson content for public courses |
| PC-B2 | API | Add permission middleware for gated features | `apps/api/src/middleware/permission.ts` | PC-B1 | Quiz/exercise endpoints check auth for public courses |
| PC-B3 | Dashboard | Implement localStorage progress tracking | `apps/dashboard/src/lib/utils/guest-progress.ts` | None | Can save/load guest progress from localStorage |
| PC-B4 | Dashboard | Add guest mode detection | `apps/dashboard/src/lib/stores/auth.svelte.ts` | None | Store exposes `isGuest` computed property |
| PC-C1 | Dashboard | Build quiz gate component | `apps/dashboard/src/lib/features/lesson/components/quiz-gate.svelte` | PC-B2 | Shows nudge when guest views quiz |
| PC-C2 | Dashboard | Build lesson complete nudge | `apps/dashboard/src/lib/features/lesson/components/lesson-complete-nudge.svelte` | PC-B3 | Shows after lesson completion for guests |
| PC-C3 | Dashboard | Build guest progress banner | `apps/dashboard/src/lib/features/lesson/components/guest-progress-banner.svelte` | PC-B3 | Shows after 2+ lessons viewed |
| PC-C4 | Dashboard | Implement progress merge flow | `apps/dashboard/src/lib/features/auth/utils/merge-progress.ts` | PC-B3 | Merges localStorage to account on signup |
| PC-C5 | Dashboard | Add guest mode UI indicators | `apps/dashboard/src/lib/features/ui/guest-indicator.svelte` | PC-B4 | Shows "Guest Mode" in header |
| PC-D1 | Dashboard | Update landing page tabs | `apps/dashboard/src/routes/org/[slug]/landing/+page.svelte` | PC-A4 | Has Live / Self Paced / Public tabs |
| PC-D2 | Dashboard | Create course type badge component | `apps/dashboard/src/lib/features/course/components/course-type-badge.svelte` | None | Shows Live/Self Paced/Public with colors |
| PC-D3 | Dashboard | Update course cards | `apps/dashboard/src/lib/features/course/components/course-card.svelte` | PC-D2 | Cards show type badge |
| PC-D4 | API | Add public courses list endpoint | `apps/api/src/routes/course/course.ts` | PC-A4 | `GET /courses/public` returns public courses |
| PC-E1 | API | Add guest analytics tracking | `apps/api/src/routes/analytics/analytics.ts` | None | Fire-and-forget endpoint for guest events |
| PC-E2 | Dashboard | Implement conversion tracking | `apps/dashboard/src/lib/utils/analytics/public-courses.ts` | PC-E1 | Tracks guest → auth conversions |
| PC-E3 | API | Add live viewer tracking | `apps/api/src/services/analytics/live-viewers.ts` | PC-B1 | Tracks active sessions per course |
| PC-E4 | Dashboard | Add live viewer count UI | `apps/dashboard/src/lib/features/course/components/live-viewer-count.svelte` | PC-E3 | Shows "X people learning now" |
| PC-E5 | Testing | Write integration tests | `apps/api/tests/public-courses.test.ts` | All above | Core flows tested |
| PC-A1 | DB | Add `PUBLIC` to `COURSE_TYPE` enum; change default to `SELF_PACED` | `packages/db/src/schema.ts`, new migration | None | Migration applied; enum accepts `PUBLIC`; default is `SELF_PACED` |
| PC-A2 | DB | Add `callout JSONB NULL` column on `course` | `packages/db/src/schema.ts`, migration | PC-A1 | Column exists; type matches `CourseCallout` shape |
| PC-A3 | DB | Add `slug VARCHAR NULL` to `lesson` and `exercise` | `packages/db/src/schema.ts`, migration | None | Columns exist on both tables |
| PC-A4 | DB | Backfill slugs for existing `lesson` and `exercise` rows (kebab-case from title, collision-resolved per course) | Migration SQL or one-shot script | PC-A3 | All rows have non-null slug; uniqueness holds per course |
| PC-A5 | DB | Follow-up migration making `lesson.slug` and `exercise.slug` `NOT NULL` | Migration | PC-A4 | Columns are `NOT NULL` |
| PC-A6 | DB queries | Update lesson / exercise queries to return `slug` | `packages/db/src/queries/course/**/*.ts` | PC-A3 | Queries project the new column |
| PC-A7 | Utils | Slug validator (Zod: kebab-case, 1–80 chars) | `packages/utils/src/validation/shared/slug.ts` | None | Schema exported and unit-tested |
| PC-A8 | Utils | Callout validator (Zod for `{ title, description, buttonLabel, buttonUrl }`) | `packages/utils/src/validation/course/callout.ts` | None | Schema exported; URL validated |
| PC-A9 | Utils | Extend course validation schemas to accept `type: 'PUBLIC'`, `callout`, and `slug` fields on lesson / exercise | `packages/utils/src/validation/course/**`, `lesson/**`, `exercise/**` | PC-A7, PC-A8 | Schemas accept new fields |
| PC-A10 | Question types | Expose an `autoGradable` filter helper for pickers (if not already exported) | `packages/question-types/src/question-type-capabilities.ts` | None | Helper available via `isAutoGradableQuestionType` / registry filter |
| PC-B1 | API service | `slugFromTitle(title, existingSlugsInCourse)` utility (generate + collision-resolve) | `apps/api/src/services/course/slug.ts` | PC-A7 | Pure function; unit tests cover collision cases |
| PC-B2 | API service | On lesson / exercise create, auto-generate `slug` if not provided | `apps/api/src/services/course/lesson.ts`, `apps/api/src/services/exercise/exercise.ts` | PC-B1 | New items get slugs automatically |
| PC-B3 | API service | On lesson / exercise update, accept slug edit; enforce per-course uniqueness across lessons + exercises | Same services | PC-B1 | Conflicting slugs are rejected with a clear error code |
| PC-B4 | API service | Block non-auto-gradable question types on public courses (create + update) | `apps/api/src/services/exercise/exercise.ts` | PC-A10 | Attempts rejected with `QUESTION_TYPE_NOT_ALLOWED_IN_PUBLIC_COURSE` |
| PC-B5 | API service | `convertCourseType(courseId, newType)` — when converting **to** `PUBLIC`, reject if any attached question is non-auto-gradable; return list of offenders | `apps/api/src/services/course/course.ts` | PC-A10 | Conversion blocked with actionable error; passes when all questions are auto-gradable |
| PC-B6 | API service | Accept `callout` on course update | `apps/api/src/services/course/course.ts` | PC-A8 | Callout round-trips through create/update/read |
| PC-B7 | API route | `GET /org-site/course/:courseSlug` (no auth) returns course + section tree + item metadata incl. slugs | `apps/api/src/routes/org-site/course.ts` (new file) | PC-A6 | Anonymous requests succeed when course is public + published; 404 otherwise |
| PC-B8 | API route | `GET /org-site/course/:courseSlug/item/:itemSlug` (no auth) returns one of two shapes with discriminator `kind: 'lesson' \| 'exercise'` | Same router | PC-B7 | Returns lesson body or exercise payload (with answer keys) |
| PC-B9 | API route | Register the new router in `app.ts`; ensure `authMiddleware` is NOT applied to this router | `apps/api/src/app.ts` | PC-B7, PC-B8 | Router mounted; smoke test as anonymous user succeeds |
| PC-C1 | Dashboard | Add `PUBLIC` option to course type picker at course creation | `apps/dashboard/src/lib/features/course/components/course-create/*` (existing) | PC-A9 | Creator can pick `Public` and create a course with `type: 'PUBLIC'` |
| PC-C2 | Dashboard | Filter question-type picker to `autoGradable` only when `course.type === 'PUBLIC'` | `apps/dashboard/src/lib/features/ui/question/constants.ts`, `packages/ui/src/custom/question-type-picker/*` | PC-A10 | Non-auto-gradable types are hidden from the picker in public courses |
| PC-C3 | Dashboard | Add slug editor to lesson and exercise settings (visible only when course is `PUBLIC`) | Lesson / exercise settings components under `features/course/components/...` | PC-A7 | Creator can edit slug; server validation errors surface inline |
| PC-C4 | Dashboard | Add **Callout Settings** panel on the course settings page (visible only when `PUBLIC`) | `apps/dashboard/src/lib/features/course/components/settings/callout-settings.svelte` (new) | PC-B6 | Creator can set / clear title, description, button label, button URL |
| PC-C5 | Dashboard | Warn in UI when switching a course **to** `PUBLIC` with non-auto-gradable questions (surface server error nicely) | Course settings type picker | PC-B5 | Clear error modal listing offending items |
| PC-D1 | UI | `PublicCourseShell` — responsive two-column layout. Desktop: sidebar + content. Mobile: hides sidebar, reserves bottom padding for the bottom bar. Accepts `sidebar`, `bottomNav`, `main` slots. | `packages/ui/src/custom/public-course/shell.svelte` | None | Component renders correctly at both breakpoints; bottom padding reserved on mobile |
| PC-D2 | UI | `PublicCourseSidebarRow` — number badge + title + lock indicator. Variants: default, hover, active (uses `--primary`), locked. | `packages/ui/src/custom/public-course/sidebar-row.svelte` | None | Matches `design/sidebar-active-state.png`; no hard-coded orange |
| PC-D3 | UI | `PublicCourseSidebar` — sections with muted headings and their rows. Per-section numbering resets across lessons + exercises interleaved by `order`. | `packages/ui/src/custom/public-course/sidebar.svelte` | PC-D2 | Sections render in order; numbering resets per section |
| PC-D4 | UI | `PublicCourseBottomNav` — mobile-only fixed bottom bar with prev / center / next. Emits `onPrev`, `onNext`, `onCenterTap`. Disables prev / next at boundaries. | `packages/ui/src/custom/public-course/bottom-nav.svelte` | None | Three regions render; disabled states work; `--primary` used for accent |
| PC-D5 | UI | `PublicCourseMobileSheet` — vaul-based drawer that slides up with the full sidebar tree inside. Drag-to-dismiss. Auto-closes on item tap. | `packages/ui/src/custom/public-course/mobile-sheet.svelte` (uses `@cio/ui/base/drawer`) | PC-D3 | Opens on center-tap; swipe-down dismisses; tapping a row navigates + closes |
| PC-D6 | UI | `PublicLessonView` — video → section label → title → body → inline callout. Video slot omitted entirely when no video. | `packages/ui/src/custom/public-course/lesson-view.svelte` | PC-D8 | Matches `design/lesson-page-full.png` |
| PC-D7 | UI | `PublicExerciseView` — renders exercise questions in client-grading mode. Uses existing question-type render contract. No server submit. | `packages/ui/src/custom/public-course/exercise-view.svelte` | None | Exercises grade locally; "you got X / Y" displayed; refresh resets state |
| PC-D8 | UI | `PublicCourseCallout` — inline variant (below body) and full variant (replaces body for locked items). | `packages/ui/src/custom/public-course/callout.svelte` | None | Both variants render; button opens URL in new tab |
| PC-D9 | Storybook | `PublicCourseSidebarRow` stories: default, hover, active, locked. | `packages/storybook/src/molecules/public-course/sidebar-row.stories.svelte` | PC-D2 | All four variants selectable |
| PC-D10 | Storybook | `PublicCourseSidebar` stories: default, with locked item, many sections. | `...sidebar.stories.svelte` | PC-D3 | Three scenarios render correctly |
| PC-D11 | Storybook | `PublicCourseBottomNav` stories: first item (prev disabled), middle item, last item (next disabled). | `...bottom-nav.stories.svelte` | PC-D4 | Three position states selectable |
| PC-D12 | Storybook | `PublicCourseMobileSheet` stories: closed, open (default), open-at-section. | `...mobile-sheet.stories.svelte` | PC-D5 | Stories demonstrate drag-to-dismiss and row-tap close |
| PC-D13 | Storybook | `PublicLessonView` stories: with video, without video, long body, short body. | `...lesson-view.stories.svelte` | PC-D6 | Four stories render |
| PC-D14 | Storybook | `PublicExerciseView` stories: unanswered, mid-answer, after-grade. | `...exercise-view.stories.svelte` | PC-D7 | Three stories render; after-grade shows correct/incorrect marks |
| PC-D15 | Storybook | `PublicCourseCallout` stories: inline, full (locked), no-callout fallback. | `...callout.stories.svelte` | PC-D8 | Three variants render |
| PC-D16 | Storybook | **Composed** `PublicCoursePage` story — mounts `Shell` + `Sidebar` + `LessonView` + `BottomNav`. Use Storybook's viewport addon to switch desktop / tablet / mobile. This is the primary review surface for design sign-off. | `...public-course-page.stories.svelte` | PC-D1..PC-D8 | Desktop shows two columns (no bottom bar); mobile hides sidebar and shows bottom bar; center-tap opens sheet |
| PC-D17 | Dashboard types | Frontend types for public tree + item responses (inferred from API). | `apps/dashboard/src/lib/features/public-course/utils/types.ts` (new feature folder) | PC-B7, PC-B8 | Typed responses usable in routes |
| PC-D18 | Dashboard route | `(org-site)/course/[slug]/+layout.server.ts` — SSR fetch of the tree, exposes to child routes. | New file | PC-D17 | Tree available via layout data |
| PC-D19 | Dashboard route | `(org-site)/course/[slug]/+page.svelte` — overview page linking into items by slug. | New file | PC-D18 | Renders overview; links use `itemSlug` |
| PC-D20 | Dashboard route | `(org-site)/course/[slug]/lesson/[itemSlug]/+page.server.ts` — SSR fetch for item content. | New file | PC-B8 | Server-rendered content on first paint |
| PC-D21 | Dashboard route | `(org-site)/course/[slug]/lesson/[itemSlug]/+page.svelte` — composes `Shell` + `Sidebar` + `LessonView` / `ExerciseView` + `BottomNav` + `MobileSheet`. | New file | PC-D16, PC-D20 | Page matches the composed Storybook story behavior |
| PC-D22 | Dashboard | Locked-item rendering: when `isUnlocked === false`, swap body for the full-variant callout. | Inside `+page.svelte` or container component | PC-D8 | Locked items render the callout; sidebar shows lock icon |
| PC-D23 | Dashboard | Wire prev / next navigation (both desktop keyboard shortcuts and mobile bottom bar) to `goto()` the next / previous `itemSlug`. | New util: `features/public-course/utils/navigation.ts` | PC-D4 | Arrow keys and bottom bar both navigate; disabled at boundaries |
| PC-E1 | SEO | Set `<title>`, `<meta description>`, canonical, and OG tags on public lesson pages. | `+page.svelte` head blocks | PC-D21 | View-source shows correct tags |
| PC-E2 | i18n | All new user-facing strings in `apps/dashboard/src/lib/utils/translations/en.json` (callout settings, locked-state fallback text, prev / next labels for a11y, etc.). | translations file | PC-C4, PC-D22 | No hardcoded English in new components |
| PC-E3 | a11y | Keyboard + screen-reader review: sidebar rows are links, bottom-nav buttons have labels, sheet traps focus while open. | All new components | PC-D16, PC-D21 | `axe` passes on the composed story and on a live page |
| PC-E4 | Tests | Service + route tests for public GET endpoints, slug uniqueness, non-auto-gradable rejection, type-conversion guard. | `apps/api/src/**/__tests__/public-course*.test.ts` | PC-B* | Tests pass |
---
## API Changes
## Schema Snippets
### New Endpoints
### Migration: enum + default + columns
```typescript
// GET /courses/public
// List all public courses (no auth required)
// Returns: Course[] filtered by type='PUBLIC' and status='PUBLISHED'
```sql
-- Add PUBLIC to the existing COURSE_TYPE enum
ALTER TYPE "COURSE_TYPE" ADD VALUE IF NOT EXISTS 'PUBLIC';
// POST /analytics/guest-event
// Track guest activity (privacy-safe, no PII)
// Body: { event: string, courseId: string, lessonId?: string, timestamp: number }
// Returns: 204 No Content
-- Change default
ALTER TABLE "course" ALTER COLUMN "type" SET DEFAULT 'SELF_PACED';
-- Callout
ALTER TABLE "course" ADD COLUMN "callout" JSONB;
-- Slugs (nullable first; NOT NULL after backfill)
ALTER TABLE "lesson" ADD COLUMN "slug" VARCHAR;
ALTER TABLE "exercise" ADD COLUMN "slug" VARCHAR;
```
### Modified Endpoints
Backfill (pseudo):
```typescript
// POST /courses
// Request body now accepts: type: 'LIVE_CLASS' | 'SELF_PACED' | 'PUBLIC'
// GET /courses/:slug/lessons/:lessonId
// Response now includes:
// - requiresAuth: boolean (for interactive elements)
// - userProgress: null | Progress (null for guests)
// POST /courses/:slug/enroll
// Auto-enrolls authenticated user to public course on first interaction
// No-op if already enrolled
```ts
// For each course, iterate lessons + exercises ordered by order,
// generate kebab-case slugs from titles, resolve collisions per course
// by appending -2, -3, ... across the union of both tables.
```
Follow-up migration:
```sql
ALTER TABLE "lesson" ALTER COLUMN "slug" SET NOT NULL;
ALTER TABLE "exercise" ALTER COLUMN "slug" SET NOT NULL;
```
(No per-course unique constraint in SQL; enforced in application code.)
---
## Data Flow
## API Response Shapes
### Guest Viewing Public Course
```
1. Guest clicks course card on landing page
2. Server checks: course.type === 'PUBLIC' → allow
3. Lesson content rendered server-side (SEO-friendly)
4. Client hydrates with guest mode indicators
5. localStorage checked for saved progress
6. Guest browses lessons freely
### Tree response
On quiz view:
7. Client checks auth status
8. Guest sees inline nudge: "Sign in to take quiz"
9. Quiz questions visible but submit disabled
On signup:
10. Progress merged from localStorage
11. Auto-enrolled in course
12. Redirected back to last viewed lesson
```
---
## UI Components Needed
### Course Type Badge
```svelte
<CourseTypeBadge type="PUBLIC" />
// Shows: 🌐 Public (green/teal)
```
### Quiz Gate
```svelte
<QuizGate isGuest={true}>
<QuizContent questions={questions} />
</QuizGate>
// Renders nudge overlay when guest tries to interact
```
### Guest Progress Banner
```svelte
<GuestProgressBanner
lessonsCompleted={3}
totalLessons={12}
onSignUp={() => goto('/signup')}
/>
```
### Landing Page Tabs
```svelte
<Tabs value={activeTab}>
<Tabs.List>
<Tabs.Trigger value="live">📅 Live Classes</Tabs.Trigger>
<Tabs.Trigger value="selfpaced">📚 Self Paced</Tabs.Trigger>
<Tabs.Trigger value="public">🌐 Public Courses</Tabs.Trigger>
</Tabs.List>
</Tabs>
```
---
## localStorage Schema
```typescript
// Key: `cio_guest_progress`
interface GuestProgressStore {
version: 1;
courses: {
[courseId: string]: {
lastVisitedLessonId: string;
completedLessons: string[];
totalTimeSpent: number; // seconds
firstVisit: string; // ISO date
lastVisit: string; // ISO date
}
};
sessionId: string; // anonymous fingerprint for analytics
```ts
GET /org-site/course/:courseSlug
// 200
{
success: true,
data: {
course: {
id: string;
slug: string;
title: string;
description: string | null;
bannerImage: string | null;
callout: {
title: string;
description: string;
buttonLabel: string;
buttonUrl: string;
} | null;
};
sections: Array<{
id: string;
title: string;
order: number;
items: Array<
| { kind: 'lesson'; id: string; slug: string; title: string; order: number; isUnlocked: boolean; hasVideo: boolean }
| { kind: 'exercise'; id: string; slug: string; title: string; order: number; isUnlocked: boolean }
>;
}>;
}
}
// 404 if course is not public or not published
```
---
### Item response (single type, discriminator)
## Permission Matrix
| Endpoint | Guest (Public Course) | Auth (Public Course) | Any (Private Course) |
|----------|----------------------|----------------------|---------------------|
| GET /courses/:slug | ✅ | ✅ | ✅ (if enrolled) |
| GET /lessons/:id | ✅ | ✅ | ✅ (if enrolled) |
| GET /quizzes/:id | ✅ (no answers) | ✅ | ✅ (if enrolled) |
| POST /quizzes/:id/submit | ❌ | ✅ | ✅ (if enrolled) |
| POST /exercises/:id/submit | ❌ | ✅ | ✅ (if enrolled) |
| GET /progress | ❌ | ✅ | ✅ (own progress) |
| POST /discussions | ❌ | ✅ | ✅ (if enrolled) |
---
## Analytics Events
```typescript
// Guest events (no userId, use sessionId)
interface GuestEvent {
event:
| 'public_course_viewed'
| 'public_lesson_started'
| 'public_lesson_completed'
| 'public_quiz_viewed'
| 'conversion_prompt_shown'
| 'conversion_prompt_clicked'
| 'guest_signup_initiated';
courseId: string;
lessonId?: string;
sessionId: string;
timestamp: number;
metadata?: Record<string, unknown>;
}
// Conversion events (with userId)
interface ConversionEvent {
event: 'guest_progress_merged' | 'public_course_enrolled';
userId: string;
courseId: string;
previousSessionId: string;
lessonsCompletedBeforeSignup: number;
timestamp: number;
```ts
GET /org-site/course/:courseSlug/item/:itemSlug
// 200
{
success: true,
data:
| {
kind: 'lesson';
id: string;
slug: string;
title: string;
sectionTitle: string;
isUnlocked: boolean;
video: { type: 'youtube' | 'upload' | 'generic' | 'google_drive'; link: string } | null;
body: string; // rich text / HTML
}
| {
kind: 'exercise';
id: string;
slug: string;
title: string;
sectionTitle: string;
isUnlocked: boolean;
questions: ExercisePayloadWithAnswerKeys; // uses question-types render contract
};
}
// 404 if course not public/published or item not found
```
---
@@ -233,76 +201,70 @@ interface ConversionEvent {
## Acceptance Criteria
### Phase A
- [ ] Can create course with type 'PUBLIC' via API
- [ ] Course type persists and returns in responses
- [ ] Existing courses default to 'SELF_PACED'
- [ ] `COURSE_TYPE` enum includes `PUBLIC`.
- [ ] `course.type` default is `SELF_PACED` for new rows.
- [ ] `course.callout` column exists and accepts null or full object.
- [ ] `lesson.slug` and `exercise.slug` exist, backfilled, and `NOT NULL`.
- [ ] Slug validator rejects invalid shapes (spaces, uppercase, >80 chars).
### Phase B
- [ ] Guest can view lesson content for public courses
- [ ] Guest cannot submit quizzes/exercises
- [ ] localStorage saves guest progress
- [ ] Progress loads from localStorage on return visit
- [ ] `GET /org-site/course/:courseSlug` returns the tree anonymously for public + published courses.
- [ ] `GET /org-site/course/:courseSlug/item/:itemSlug` returns lesson or exercise content with `kind` discriminator.
- [ ] Anonymous request on a non-public or unpublished course returns 404.
- [ ] Creating / updating a question with a non-auto-gradable type on a PUBLIC course is rejected with a clear error.
- [ ] Converting a course to `PUBLIC` fails when non-auto-gradable questions exist; error lists the offending items.
- [ ] Lesson / exercise PATCH accepts `slug` edits and enforces per-course uniqueness across both tables.
### Phase C
- [ ] Quiz gate shows for guests viewing quizzes
- [ ] Lesson complete nudge shows after finishing lesson
- [ ] Progress banner shows after 2+ lessons
- [ ] Signup merges localStorage progress to account
- [ ] Post-signup redirects to correct lesson
- [ ] Creator can select `PUBLIC` as a course type.
- [ ] Question-type picker hides non-auto-gradable types when the course is `PUBLIC`.
- [ ] Slug field is visible and editable on lesson / exercise settings (only when course is `PUBLIC`).
- [ ] Callout settings panel is visible on the course settings page (only when `PUBLIC`) and round-trips the callout object.
### Phase D
- [ ] Landing page has Public Courses tab
- [ ] Course cards show correct type badge
- [ ] Public courses list endpoint works without auth
- [ ] Every new component in `packages/ui/src/custom/public-course/*` has at least one Storybook story (listed in PC-D9..PC-D16).
- [ ] The composed `PublicCoursePage` story renders correctly at desktop, tablet, and mobile viewports (via Storybook's viewport addon).
- [ ] Desktop: two columns, no bottom bar, sidebar visible.
- [ ] Mobile: sidebar hidden, fixed bottom bar visible with prev / center / next regions; center tap opens a drag-dismissible bottom sheet containing the full sidebar tree; tapping a row navigates and closes the sheet.
- [ ] Sidebar numbers reset per section; active state uses `--primary`; hover state matches the reference designs.
- [ ] Locked items render the creator callout in place of content; sidebar shows a lock indicator.
- [ ] Exercises grade entirely client-side; no network request on answer submit.
- [ ] No hard-coded orange anywhere; all accents resolve to `--primary`.
- [ ] `(org-site)/course/[slug]` renders the course overview.
- [ ] `(org-site)/course/[slug]/lesson/[itemSlug]` matches the composed Storybook story on a live browser.
- [ ] Anonymous visitors see the same experience regardless of auth state.
### Phase E
- [ ] Guest events tracked (privacy-safe)
- [ ] Conversion funnel metrics calculated
- [ ] Live viewer count displays correctly
- [ ] Donation button appears when enabled
- [ ] Download permissions enforced correctly
- [ ] Course type conversion works with grace period
- [ ] Integration tests pass
- [ ] No console errors in guest mode
- [ ] View-source on a public lesson page shows the item title, description, canonical URL, and OG tags.
- [ ] All user-facing strings are translation keys.
- [ ] Keyboard navigation works on desktop (`←` / `→` move through items); bottom-nav buttons have a11y labels; the mobile sheet traps focus while open.
- [ ] `axe` passes on both the composed Storybook story and a live public lesson page.
- [ ] API tests pass.
---
## Verification Commands
```bash
# Build all packages
pnpm --filter @cio/utils build
pnpm --filter @cio/question-types build
pnpm --filter @cio/db build
pnpm --filter @cio/api build
pnpm --filter @cio/ui build
pnpm --filter @cio/dashboard build
# Run tests
pnpm --filter @cio/api test public-courses
# Type check
pnpm --filter @cio/api test public-course
pnpm --filter @cio/dashboard check
```
---
## Rollout Plan
1. **Feature Flag**: Add `PUBLIC_COURSES_ENABLED` env var
2. **Internal Testing**: Enable for team org only
3. **Beta Users**: Select 5 orgs to test
4. **Full Rollout**: Remove feature flag
5. **Announcement**: Blog post + in-app notification
---
## Risk Mitigation
## Risks & Mitigations
| Risk | Mitigation |
|------|------------|
| Content scraping | Rate limiting on lesson endpoints; consider Cloudflare bot detection |
| Live viewer accuracy | Use heartbeat every 2 min, expire after 5 min of inactivity |
| Donation fraud | Validate Stripe link format; no server-side payment processing |
| SEO duplicate content | Canonical URLs; no separate /public/ path |
| localStorage quota exceeded | Max 10 courses tracked; LRU eviction |
| Guest analytics spam | Rate limit analytics endpoint; validate sessionId format |
| Conversion nudge fatigue | Frequency caps; dismissible with "Don't show again" |
| Slug collisions during backfill | Two-pass backfill script: generate all candidates, resolve collisions per course, then write. Wrap in a transaction and dry-run in staging first. |
| Answer keys shipped to the client | Accepted trade-off (public courses are marketing surfaces). Documented in the PRD. |
| Converting a course with many non-auto-gradable questions to `PUBLIC` is painful | Server error lists every offending question with its exercise + section so the creator can fix them in one pass. |
| SEO duplicate content between `(app)/courses/[id]/lessons/[lessonId]` and the new public URL | Canonical URL on public pages points at the `(org-site)` URL. `(app)` URLs remain auth-gated and are not indexed. |
| Hard-coded orange in screenshots leaking into the build | Explicit rule in the PRD: all accents use `--primary`. Reviewer should grep new components for raw orange hex values. |