feat: live class sessions, CE workflow, and docs (#683)

Adds live-class session scheduling on lessons (join link, date/time,
timezone) with automatic 24h and 1h reminder emails plus a calendar
invite (.ics), a time-change notification with an updated invite, a
teacher-authored welcome email and session invite on enrollment, and a
"Notify students" quiz nudge.

LMS gets an upcoming live-sessions card and Join buttons, plus a
reusable live-session card component (3 states) in @cio/ui with a
Storybook story. The sidebar content tree now reflects sequential
progression locking like the content list, and the course-completion
confetti regression is fixed.

Org-based emails no longer reference ClassroomIO by name. Docs add
live-class, welcome-email, and course-progression guides, expand the
certificates guide (design, signatures, corrected labels), move
plan-required callouts to the top, flatten how-to-guides to the docs
root with redirects, and add screenshots. Includes an add-docs-image
skill.
This commit is contained in:
Rotimi Best
2026-06-22 05:43:48 +01:00
committed by GitHub
parent 3547901de6
commit 81294a47f9
193 changed files with 5477 additions and 805 deletions
@@ -28,6 +28,8 @@ import {
import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { toast } from '@cio/ui/base/sonner';
import { t } from '$lib/utils/functions/translations';
/**
* API class for exercise operations
@@ -60,6 +62,74 @@ export class ExerciseApi extends BaseApiWithErrors {
});
}
/**
* Notifies all course members that a quiz has been assigned. Runs as a
* background job; shows a sonner spinner that polls until the job is done.
*/
async notifyStudents(courseId: string, exerciseId: string) {
const toastId = toast.loading(t.get('course.navItem.lessons.exercises.all_exercises.notify.sending'), {
position: 'bottom-right'
});
try {
const res = await classroomio.course[':courseId'].exercise[':exerciseId'].notify.$post({
param: { courseId, exerciseId }
});
const body = await res.json();
if (!body.success) {
throw new Error('Failed to start notification');
}
const jobId = body.data.jobId;
const maxPolls = 60;
for (let pollCount = 0; pollCount < maxPolls; pollCount++) {
const statusRes = await classroomio.course[':courseId'].exercise[':exerciseId'].notify[':jobId'].$get({
param: { courseId, exerciseId, jobId },
query: { pollCount: String(pollCount) }
});
const statusBody = await statusRes.json();
if (!statusBody.success) {
throw new Error('Failed to read notification status');
}
const { job, nextPollMs } = statusBody.data;
if (job.status === 'completed') {
const notified = (job.result as { notified?: number } | null)?.notified ?? 0;
toast.success(t.get('course.navItem.lessons.exercises.all_exercises.notify.sent', { count: notified }), {
id: toastId,
position: 'bottom-right'
});
return;
}
if (job.status === 'failed' || job.status === 'canceled') {
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
return;
}
await new Promise((resolve) => setTimeout(resolve, nextPollMs || 2000));
}
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
} catch (error) {
console.error('notifyStudents error:', error);
toast.error(t.get('course.navItem.lessons.exercises.all_exercises.notify.failed'), {
id: toastId,
position: 'bottom-right'
});
}
}
/**
* Creates a new exercise
*/