chore: ai, public course and other ui improvements
This commit is contained in:
+243
-430
@@ -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 |
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user