# @hiyve/react-courses

React components for structured courses — a provider builds courses and assigns them to their own clients, who work through them at their own pace.

- **Clients** get a course library (`MyCourses`) and a course player (`CoursePlayer`): a sidebar outline showing what is done, what is open and why the rest is locked; each lesson's video, audio, documents, text, embeds, quizzes, live session rooms and your app's own blocks; and **Complete & continue**. Video and audio pick up where the client left off. Progress saves itself.
- **Providers** get a course library (`CourseLibrary`), a builder (`CourseBuilder`) — an outline of sections and lessons, each lesson's blocks, and unlock rules (prerequisites, drip timers, approval) — and a clients screen (`CourseClients`) to enroll clients from your roster, approve or send back lessons, and follow progress.

The host app owns branding (through the MUI theme), users and notifications: the components report what happens through event callbacks. A client's events (a completed lesson, a submission, a reply) are reported once their progress is saved, so a notification never points at work that isn't stored; a submission, reply or question that can't be saved fails rather than looking sent, the player shows where saving stands, and leaving the page with unsaved work gets the browser's warning.

## Installation

```bash
npm install @hiyve/react-courses @hiyve/courses @hiyve/react @hiyve/core @hiyve/react-media-player \
  @hiyve/react-assignments @hiyve/react-collaboration @hiyve/react-clips @hiyve/react-ui \
  @hiyve/react-semantic-relay @hiyve/rtc-client @hiyve/utilities
```

The player, PDF viewer, text editor and recorder are Hiyve's own components, so their packages are needed even when you bring your own `CourseStorage`.

## Quick start

```tsx
import { useState } from 'react';
import { createMemoryCourseStorage } from '@hiyve/courses';
import { CourseProvider, CoursePlayer, MyCourses } from '@hiyve/react-courses';

const storage = createMemoryCourseStorage({ userId: 'alex@example.com' });

function ClientCourses() {
  const [enrollmentId, setEnrollmentId] = useState<string | null>(null);
  return (
    <CourseProvider storage={storage} currentUser={{ userId: 'alex@example.com', userName: 'Alex' }}>
      {enrollmentId ? (
        <CoursePlayer enrollmentId={enrollmentId} onBack={() => setEnrollmentId(null)} />
      ) : (
        <MyCourses onOpenCourse={setEnrollmentId} />
      )}
    </CourseProvider>
  );
}
```

A provider's side looks the same with `CourseLibrary`, `CourseBuilder` and `CourseClients`:

```tsx
import { CourseBuilder, CourseClients, CourseLibrary, CourseProvider } from '@hiyve/react-courses';

<CourseProvider storage={storage} currentUser={{ userId: 'coach@example.com', userName: 'Jordan' }} onApprovalRequested={notifyProvider}>
  {route.view === 'library' && <CourseLibrary onOpenCourse={openBuilder} onOpenClients={openClients} />}
  {route.view === 'builder' && <CourseBuilder courseId={route.courseId} onBack={openLibrary} onPreview={openPreview} onOpenClients={openClients} />}
  {route.view === 'clients' && <CourseClients courseId={route.courseId} roster={myClients} onBack={openLibrary} onOpenBuilder={openBuilder} />}
  {route.view === 'preview' && <CoursePlayer previewCourseId={route.courseId} onBack={openBuilder} />}
</CourseProvider>
```

### With Hiyve

Keep courses in Hiyve user files with `useHiyveCourseStorage` from the `/hiyve` entry, inside `HiyveProvider`:

```tsx
import { CourseProvider, CourseLibrary } from '@hiyve/react-courses';
import { useHiyveCourseStorage } from '@hiyve/react-courses/hiyve';

function Courses({ userId }: { userId: string }) {
  const { storage } = useHiyveCourseStorage({ userId });
  return (
    <CourseProvider storage={storage} currentUser={{ userId }}>
      <CourseLibrary onOpenCourse={openBuilder} />
    </CourseProvider>
  );
}
```

| Option | Type | Description |
|---|---|---|
| `userId` | `string` | The signed-in user |
| `location` | `string` | Folder for course documents. Default `/Courses` |
| `isProviderTrusted` | `(providerId: string) => boolean` | Which providers this user accepts courses from, by cleaned user id (`cleanUserId` from `@hiyve/core`). Everyone by default. A new function on a later render is used from then on without rebuilding the storage |

Set `isProviderTrusted` in any app where anyone can share files with the user — by default a course from anyone who can is accepted and listed. It returns `{ storage, canWrite }`. Changing a saved course or saved progress needs a room or no-video room connection: keep one open while course screens are in use. Until it is, `canWrite` is `false` and the components hold changes and save them once it opens.

## CourseProvider

Wraps every course component.

| Prop | Type | Description |
|---|---|---|
| `storage` | `CourseStorage` | Where courses are kept (see `@hiyve/courses`) |
| `currentUser` | `CourseUser` | `{ userId, userName? }` of the signed-in user |
| `blockTypes` | `AnyCourseBlockType[]` | Block types your app adds (see Custom blocks) |
| `labels` | `Partial<CourseLabels>` | Text overrides |
| `colors` | `Partial<CourseColors>` | Color overrides |
| `allowedEmbedOrigins` | `string[]` | Sites embed blocks may show. Default: YouTube, Vimeo, Loom |
| `resolveUserName` | `(userId) => string \| undefined` | Display names for providers and clients |
| `now` | `() => number` | Clock in epoch ms |
| `maxUploadBytes` | `number` | Largest file anyone can upload through the components — lesson media, submissions, recordings, photos, comment attachments and custom blocks. A larger file is refused with a message before anything is sent. No limit by default; set it to what your storage accepts |
| `onError` | `(error: Error) => void` | Something failed to load or save (the clients screen also shows its own failures inline) |
| `onLessonCompleted` | `({ enrollment, lessonId }) => void` | A client completed a lesson |
| `onApprovalRequested` | `({ enrollment, lessonId }) => void` | A client completed a lesson that needs approval — notify the provider |
| `onCourseCompleted` | `({ enrollment }) => void` | A client finished a course |
| `onLessonsUnlocked` | `({ courseId, enrollmentId, clientId, lessonIds }) => void` | Lessons opened to a client — notify the client |
| `onClientEnrolled` | `({ enrollment }) => void` | The provider enrolled a client |
| `onLessonDecided` | `({ enrollment, lessonId, approved, note? }) => void` | The provider approved a lesson or sent it back |
| `onJoinLiveSession` | `({ block, courseId, lessonId }) => void` | A client wants to join a live session's room — route them to it. Without it, the Join button is disabled |
| `onSubmissionCreated` | `({ enrollment, lessonId, submission }) => void` | A client submitted work — notify the provider |
| `onSubmissionReviewed` | `({ enrollment, submissionId, comment?, status? }) => void` | The provider commented or gave a status — notify the client |
| `onReplyAdded` | `({ enrollment, submissionId, reply }) => void` | A client replied to a review — notify the provider |
| `onLessonCommentAdded` | `(event: { enrollment, lessonId, comment }) => void` | A client asked a question or left a note on a lesson |
| `onLessonReplyAdded` | `(event: { enrollment, lessonId, reply }) => void` | The provider replied on a lesson |

## Client components

### MyCourses

The client's courses: where they left off, and every course with its progress, filterable by All / In progress / Completed.

| Prop | Type | Description |
|---|---|---|
| `onOpenCourse` | `(enrollmentId: string) => void` | The client opens a course |
| `sx` | `SxProps<Theme>` | Styles |

### CoursePlayer

| Prop | Type | Description |
|---|---|---|
| `enrollmentId` | `string` | The client's enrollment to play |
| `previewCourseId` | `string` | A course of the current provider's to preview as a client sees it; everything is open and nothing is saved |
| `initialLessonId` | `string` | Lesson to open first. Default: where the client left off |
| `onBack` | `() => void` | Shows a back button |
| `sx` | `SxProps<Theme>` | Styles |

**Submissions:** a submission block lets the client answer by recording video or audio in the browser (with the Hiyve clip recorder), taking a photo, uploading a file or writing — whichever the provider allowed. Every submission is kept; the latest shows the provider's status and comments (each pinned to a moment, page or area — select one to jump to it), and the client can reply or submit again. Recording and photos need a secure page (HTTPS) and the browser's camera and microphone permission; on a page that isn't secure, the client is offered an upload instead (on phones this can still open the camera).

If one block can't be shown — a player or viewer fails in the browser — the rest of the lesson keeps working: that block shows a short notice with a link to open its file and, for a required video, audio or custom block, **Mark as done** so the client isn't stuck. The failure is reported to `onError`.

How lessons complete: **Complete & continue** completes the lesson and moves to the next open one. Until every required video and audio block has played (to `completeAt`, default 90%, or the end), every required quiz is submitted (or passed, with a pass mark), every required submission block has a submission and every required custom block reports completion, the button is disabled and says what is left. Text, files, embeds and live sessions complete when the client continues. A lesson that needs approval then waits for the provider; if sent back, the client sees the note and can submit again.

## Provider components

### CourseLibrary

The provider's courses with client counts, average progress and lessons waiting for approval; create, publish, archive and delete courses. With more than a few courses, a search box and a sort (recently edited, title, most clients, needs attention) appear above the list. A new course starts as a **draft**: it can be built and previewed, but no one can be enrolled until it is published. **Archive** takes a course out of the list and the dashboard, keeps it for the clients who have it, and closes it to new ones; **Restore** brings it back.

| Prop | Type | Description |
|---|---|---|
| `onOpenCourse` | `(courseId: string) => void` | Open a course in the builder (also called after creating one) |
| `onOpenClients` | `(courseId: string) => void` | Open a course's clients. Shows the Clients button and the review banner |
| `onCourseCreated` | `(course: Course) => void` | A course was created (also from a template) |
| `templates` | `CourseTemplate[]` | Starter templates your app offers |
| `sx` | `SxProps<Theme>` | Styles |

### CourseBuilder

An outline of sections and lessons (drag to reorder, or use each item's menu), the selected lesson's details and blocks, and its unlock rules. **Details** sets the description and the cover — an image shown with the course in lists, or a color from `coverPalette` when there's no image (without a choice, a color is picked from the course id). A draft shows a **Publish course** button in place of Enroll clients. Each lesson's unlock rules include a **Due** date (days after enrollment, or a date); clients see it in the outline and on the lesson, and an unfinished lesson past it reads **Overdue**, without locking. Changes save in the background; the header shows whether everything is saved. A failed save is tried again after a pause, and if the course or a lesson was saved from somewhere else (another tab) while open, the edits made here are saved over it and `onError` is told, so your app can say so. A release date is the start of that day in the provider's time zone. A quiz can be auto-scored (answers travel with the lesson) or reviewed by the provider (answers never reach clients); an auto-scored quiz can limit how many tries a client gets, after which it waits for the provider.

| Prop | Type | Description |
|---|---|---|
| `courseId` | `string` | The course to edit |
| `onBack` | `() => void` | Shows a back button |
| `onPreview` | `(courseId: string) => void` | Shows a Preview as client button |
| `onOpenClients` | `(courseId: string) => void` | Shows an Enroll clients button |
| `sx` | `SxProps<Theme>` | Styles |

### CourseClients

Enroll clients from your roster, review what is waiting — completed lessons that need approval (with the client's quiz answers and your private score) and new submissions — and follow each client's progress: where they are, when they were last active, pause or resume, unlock a lesson by hand, or remove them.

| Prop | Type | Description |
|---|---|---|
| `courseId` | `string` | The course |
| `roster` | `ClientRef[]` | Your clients, `{ userId, userName? }`, to enroll from |
| `onBack` | `() => void` | Shows the Courses breadcrumb as a link |
| `onOpenBuilder` | `(courseId: string) => void` | Shows an Edit course button |
| `sx` | `SxProps<Theme>` | Styles |

Every lesson ends with **Questions & notes**: a client can ask a question or leave a note (with a file attached if they like), and the provider answers from the review queue, where a lesson with an unanswered question appears alongside completions and submissions; the reply shows up under the lesson for the client. A lesson's review shows the client's latest quiz answers and, when they tried more than once, how many tries and their best score. Reviewing a submission: play the recording, look at the photo or page through the document, and add comments — pinned to the current moment, page or a dragged-out area of a photo, with an optional recorded or attached reply. Give a status from your own list (**Edit statuses** sets labels, tone, and whether a status approves the lesson or sends it back); the note is sent with it.

`CourseLibrary` and `CourseClients` open lessons that come due (drip timers, approvals) while they are on screen. To keep lessons opening from elsewhere in your provider app, call `useAccessSync(courseIds)`.

### ProviderDashboard

The provider's practice at a glance: active clients, what's waiting for review, who is falling behind and how many have finished; a **review inbox** across every course (the same review cards as `CourseClients`); a **falling behind** list (lessons overdue, never started, or no activity for `behindAfterDays`); and each course's clients, average progress, finishes and pending reviews.

| Prop | Type | Description |
|---|---|---|
| `onOpenClients` | `(courseId: string) => void` | Open a course's clients |
| `onOpenCourse` | `(courseId: string) => void` | Open a course in the builder. Shows an Edit course button |
| `behindAfterDays` | `number` | Days without activity before a client counts as behind. Default 7 |
| `sx` | `SxProps<Theme>` | Styles |

### Templates, duplicates and lessons for one client

- `CourseLibrary` lists the provider's own templates and your app's starters (`templates` prop, `CourseTemplate[]` from `@hiyve/courses`) with **Use template**; each course's menu has **Duplicate** and **Save as template**. Templates are edited in the builder like any course but can't have clients.
- `CourseClients` lists everyone in the course; pausing a client takes their open lessons back until they are resumed; with more than a few, a search box and a sort (name, progress, last active, status) appear above the list. A client's menu has **Mark a lesson complete** (it counts as done and approved, and opens what follows) and **Reset progress** (asks first; what they did stops counting and their app clears it when they next open the course), as well as **Customize for {name}**: hide course lessons from them, and add, edit or remove lessons just for them (the same block editors as the builder). The client sees their own lessons marked **Just for you**; no one else sees them.

## Custom blocks

Add your app's own activities to courses. Register them on `CourseProvider` and they appear in the builder's block palette and in the player.

```tsx
import type { CourseBlockType } from '@hiyve/react-courses';

const checklist: CourseBlockType<{ items: string[] }> = {
  kind: 'acme.checklist',
  version: 1,
  label: 'Checklist',
  createDefault: () => ({ items: ['First step'] }),
  summarize: (data) => `${data.items.length} steps`,
  Editor: ({ data, onChange }) => <ChecklistEditor items={data.items} onChange={(items) => onChange({ items })} />,
  View: ({ data, readOnly, onComplete }) => <Checklist items={data.items} disabled={readOnly} onAllChecked={() => onComplete()} />,
};

<CourseProvider storage={storage} currentUser={user} blockTypes={[checklist]}>…</CourseProvider>
```

| Field | Description |
|---|---|
| `kind`, `version` | Unique namespaced kind, and the version of `data`'s shape |
| `label`, `icon` | Shown in the block palette |
| `createDefault()` | Data for a new block |
| `migrate(data, fromVersion)` | Turn older data into the current shape |
| `fileRefs(data)` | Stored files the block uses, shared with clients along with the lesson |
| `summarize(data)` | One line for the builder's block list |
| `Editor` | `CustomBlockEditorProps`: `data`, `onChange`, `uploadMedia` (rejects with a message ready to show when a file is over `maxUploadBytes`), `resolveMediaUrl` |
| `View` | `CustomBlockViewProps`: `block`, `data`, `progress`, `readOnly`, `onProgress`, `onComplete(result?)`, `resolveMediaUrl` |

A block whose kind isn't registered shows a short notice, keeps its data, and completes when the client continues.

## Hooks

Build your own screens on the same logic.

| Hook | Returns |
|---|---|
| `useMyCourses()` | The client's courses with progress, next lesson and next drip (`MyCourseItem[]`) |
| `useCoursePlayer({ enrollmentId \| previewCourseId, initialLessonId? })` | Course, enrollment, progress, lesson states, current lesson and content, what is still pending, and actions (`UseCoursePlayerResult`) |
| `useCourses()` | The provider's courses and templates with client counts, average progress and pending approvals, plus `createCourse`, `deleteCourse`, `duplicate` and `createFromTemplate` (`CourseListItem[]`) |
| `useCourseBuilder(courseId)` | The course being edited, the selected lesson's blocks, save state, and every edit action (`UseCourseBuilderResult`) |
| `useCourseClients(courseId)` | Client rows, the review queue, the provider's statuses, and enroll / decide / review / pause / remove / unlock / loadReview / saveStatuses (`UseCourseClientsResult`) |
| `useProviderDashboard({ behindAfterDays? })` | Totals, the clients falling behind and each course's numbers (`UseProviderDashboardResult`, `BehindClient`, `DashboardCourse`) |
| `useAccessSync(courseIds)` | Opens due lessons while mounted |
| `useCourseContext()` | The surrounding `CourseProvider`'s values (`CourseContextValue`) |

`BlockView` shows a single block as the player does. `SubmissionViewer` shows one submission with its comments and a comment box (`SubmissionViewerProps`), for building your own review screens. `SaveState` is `'saved' | 'unsaved' | 'saving' | 'waiting' | 'error'`. Other result types: `PendingBlock`, `ClientRow`, `ClientStatus`, `ReviewItem`, `ReviewAnswers`, `ReviewDetails`, `NewBlockType`, `UseMyCoursesResult`, `UseCoursesResult`, `UseCoursePlayerOptions`, `BlockViewProps`.

## Phones and accessibility

- **Narrow screens.** Below about 860px of width (measured on the component, so a narrow panel counts too) the builder shows the outline, then the chosen lesson with a way back to it; the player shows the lesson first, with the course outline a tap away; `CourseClients` lists clients as cards. Wide tables scroll inside their own box rather than widening the page.
- **Keyboard.** Everything works without a mouse: sections and lessons move with their menus (including to another section) as well as by dragging; on a photo, Enter marks an area to comment on, arrow keys move it and Shift + arrow keys resize it.
- **Screen readers.** The outline reads out each lesson's status; moving to another lesson moves focus to its title; the text editor is named; saving status is announced.

## Customization

- **Text:** every string is in `CourseLabels`; pass overrides as `labels`. `defaultCourseLabels` and `mergeCourseLabels` are exported. Labels that take values are functions (e.g. `lessonsProgress(done, total)`, `formatDate(iso)`, `relativeTime(iso, now)`).
- **Colors:** buttons, links and progress use your MUI theme. Status colors (`completed`, `awaiting`, `awaitingBackground`, `locked`, `activeBackground`; the defaults are darkened from your theme where needed so text in them is readable) and course cover colors (`coverPalette`, offered in the builder and used when a course has no cover image) are in `CourseColors`; `buildDefaultCourseColors(theme)` and `mergeCourseColors(colors, theme)` are exported.
- **Timing:** `PROGRESS_SAVE_IDLE_MS` (5 s), `PROGRESS_SAVE_MAX_WAIT_MS` (30 s), `BUILDER_SAVE_IDLE_MS` (1.2 s) and `ACCESS_SYNC_INTERVAL_MS` (5 min) say how often things save and lessons are checked.

## Types

`CourseProviderProps`, `ProviderDashboardProps`, `CourseUser`, `CourseEvents`, `CourseLabels`, `CourseColors`, `CourseBlockType`, `AnyCourseBlockType`, `CustomBlockEditorProps`, `CustomBlockViewProps`, `MyCoursesProps`, `CoursePlayerProps`, `CourseLibraryProps`, `CourseBuilderProps`, `CourseClientsProps`.

## Requirements

- React 18 or 19, MUI v9 (`@mui/material`, `@mui/icons-material`), Emotion.
- `@hiyve/courses`, `@hiyve/react`, `@hiyve/core`, `@hiyve/react-media-player`, `@hiyve/react-assignments`, `@hiyve/react-collaboration`, `@hiyve/react-clips`, `@hiyve/react-ui`, `@hiyve/react-semantic-relay`, `@hiyve/rtc-client`, `@hiyve/utilities`.
- Recording and photos: a secure context (HTTPS or localhost) and camera/microphone permission.
- For the `/hiyve` entry: `HiyveProvider` above the components.
