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
@@ -286,7 +286,7 @@
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked && !isEditingItem}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
@@ -14,7 +14,11 @@
import { ContentType } from '@cio/utils/constants/content';
import { snackbar } from '$features/ui/snackbar/store';
import type { CourseContentItem } from '$features/course/utils/types';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import {
openCourseCompletionModal,
updateCourseCompletionModal,
closeCourseCompletionModal
} from '$features/course/store/course-completion-modal';
import { updateLessonCompletionInCourseContent } from '$features/course/utils/content-completion';
interface Props {
@@ -135,7 +139,19 @@
navigableContentItems.every((item) => item.isComplete);
if (allComplete) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
openCourseCompletionModal(courseId);
const certRes = await courseApi.getCertificationEvaluation(courseId);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseId, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseId, 'not-eligible', certRes.data, requiredExerciseId);
}
} else {
closeCourseCompletionModal();
}
}
} else {
snackbar.error('snackbar.lessons.error.try_later');
@@ -2,10 +2,13 @@
import { resolve } from '$app/paths';
import LockIcon from '@lucide/svelte/icons/lock';
import { InputField } from '@cio/ui/custom/input-field';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import { CircleCheckIcon } from '$features/ui/icons';
import type { Snippet } from 'svelte';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
import type { CourseContentItem } from '$features/course/utils/types';
import { ContentType } from '@cio/utils/constants/content';
import { t } from '$lib/utils/functions/translations';
import { cn } from '@cio/ui/tools';
interface Props {
@@ -62,6 +65,16 @@
<CourseContentIcon type={item.type} size={iconSize} />
{/if}
{#if item.type === ContentType.Lesson && item.callUrl}
<span
class="shrink-0"
title={$t('course.navItem.lessons.session.live_indicator')}
aria-label={$t('course.navItem.lessons.session.live_indicator')}
>
<RadioIcon size={iconSize} color="#ef4444" />
</span>
{/if}
<a
href={resolve(href, {})}
class={cn(
@@ -517,7 +517,7 @@
? $t('course.navItem.lessons.add_lesson.lock')
: $t('course.navItem.lessons.add_lesson.unlock')}
{@const canRenderJoinButton =
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked}
item.type === ContentType.Lesson && isLiveCourse && Boolean(item.callUrl) && !itemLocked && !isEditingItem}
{@const canRenderContinueButton = isStudentView && !itemLocked}
{@const isLockPending = Boolean(lockPendingItemKeys[getItemLockKey(item)])}
{@const actionDisabled = Boolean($contentEditingStore && !isEditingItem)}
@@ -1,14 +1,123 @@
<script lang="ts">
import { lessonApi } from '$features/course/api';
import { lessonApi, courseApi } from '$features/course/api';
import { InputField } from '@cio/ui/custom/input-field';
import { Checkbox } from '@cio/ui/base/checkbox';
import { Button } from '@cio/ui/base/button';
import * as Dialog from '@cio/ui/base/dialog';
import * as Field from '@cio/ui/base/field';
import * as Select from '@cio/ui/base/select';
import * as Tooltip from '@cio/ui/base/tooltip';
import InfoIcon from '@lucide/svelte/icons/info';
import { toast } from '@cio/ui/base/sonner';
import { classroomio } from '$lib/utils/services/api';
import { t } from '$lib/utils/functions/translations';
import { getBrowserTimezone, instantToZonedWallClock, zonedWallClockToInstant } from '$lib/utils/functions/date';
import { getVideoTitle, isHlsUploadVideo, type LessonVideo } from './video/video-card-utils';
type LessonCompletionPolicy = 'manual' | 'video_watch' | 'none';
// ── Live session (Zoom link + scheduled time + timezone) ──────────────────
const isLiveClass = $derived(courseApi.course?.type === 'LIVE_CLASS');
const timezoneOptions = (() => {
try {
return (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
} catch {
return [];
}
})();
let sessionCallUrl = $state('');
let sessionWallClock = $state('');
let sessionTimezone = $state(getBrowserTimezone());
let sessionInitializedFor = $state<string | null>(null);
let isSavingSession = $state(false);
let confirmOpen = $state(false);
$effect(() => {
const lesson = lessonApi.lesson;
if (!lesson || sessionInitializedFor === lesson.id) return;
const tz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone();
sessionTimezone = tz;
sessionCallUrl = lesson.callUrl ?? '';
sessionWallClock = instantToZonedWallClock(lesson.lessonAt, tz);
sessionInitializedFor = lesson.id;
});
const hadPriorSession = $derived(Boolean(lessonApi.lesson?.lessonAt && lessonApi.lesson?.callUrl));
function computeNewInstant(): string {
return sessionWallClock ? zonedWallClockToInstant(sessionWallClock, sessionTimezone) : '';
}
function sessionChanged(): boolean {
const currentTz = courseApi.course?.metadata?.sessionTimezone || getBrowserTimezone();
return (
sessionCallUrl !== (lessonApi.lesson?.callUrl ?? '') ||
computeNewInstant() !== (lessonApi.lesson?.lessonAt ?? '') ||
sessionTimezone !== currentTz
);
}
async function persistSession() {
const courseId = courseApi.course?.id;
const lessonId = lessonApi.lesson?.id;
if (!courseId || !lessonId) return;
isSavingSession = true;
try {
await lessonApi.update(courseId, lessonId, {
callUrl: sessionCallUrl || undefined,
lessonAt: computeNewInstant() || undefined
});
const currentTz = courseApi.course?.metadata?.sessionTimezone || '';
if (sessionTimezone && sessionTimezone !== currentTz) {
await courseApi.update(courseId, {
metadata: { ...(courseApi.course?.metadata ?? {}), sessionTimezone }
});
}
sessionInitializedFor = null; // re-sync baselines from refreshed lesson/course
} finally {
isSavingSession = false;
}
}
async function notifySessionUpdate() {
const courseId = courseApi.course?.id;
const lessonId = lessonApi.lesson?.id;
if (!courseId || !lessonId) return;
try {
await classroomio.course[':courseId'].lesson[':lessonId']['notify-session-update'].$post({
param: { courseId, lessonId }
});
toast.success(t.get('course.navItem.lessons.session.notified'));
} catch {
toast.error(t.get('course.navItem.lessons.session.notify_failed'));
}
}
async function handleSaveSession() {
if (!sessionChanged()) return;
if (hadPriorSession) {
confirmOpen = true;
return;
}
await persistSession();
toast.success(t.get('course.navItem.lessons.session.saved'));
}
async function confirmSaveAndNotify() {
confirmOpen = false;
await persistSession();
await notifySessionUpdate();
}
const completionPolicy = $derived(lessonApi.lesson?.completionPolicy ?? 'manual');
const videoWatchThreshold = $derived(lessonApi.lesson?.videoWatchThreshold ?? 95);
const videos = $derived(lessonApi.lesson?.videos ?? []);
@@ -63,6 +172,59 @@
<div class="p-4">
<Field.Group class="w-full max-w-xl">
{#if isLiveClass}
<Field.Set>
<Field.Legend>{$t('course.navItem.lessons.session.heading')}</Field.Legend>
<Field.Description>{$t('course.navItem.lessons.session.intro')}</Field.Description>
<Field.Field>
<InputField
type="url"
label={$t('course.navItem.lessons.session.link_label')}
placeholder="https://zoom.us/j/..."
value={sessionCallUrl}
onInputChange={(e) => (sessionCallUrl = (e.currentTarget as HTMLInputElement).value)}
/>
</Field.Field>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start">
<Field.Field class="sm:flex-1">
<Field.Label for="session-datetime">{$t('course.navItem.lessons.session.datetime_label')}</Field.Label>
<input
id="session-datetime"
type="datetime-local"
class="ui:border-input ui:bg-background ui:text-foreground ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-sm ui:outline-none"
value={sessionWallClock}
oninput={(e) => (sessionWallClock = (e.currentTarget as HTMLInputElement).value)}
/>
</Field.Field>
<Field.Field class="sm:flex-1">
<Field.Label for="session-timezone">{$t('course.navItem.lessons.session.timezone_label')}</Field.Label>
<select
id="session-timezone"
class="ui:border-input ui:bg-background ui:text-foreground ui:shadow-xs ui:flex ui:h-9 ui:w-full ui:rounded-md ui:border ui:px-3 ui:py-1 ui:text-sm ui:outline-none"
value={sessionTimezone}
onchange={(e) => (sessionTimezone = (e.currentTarget as HTMLSelectElement).value)}
>
{#each timezoneOptions as tz (tz)}
<option value={tz}>{tz}</option>
{/each}
</select>
<Field.Description>{$t('course.navItem.lessons.session.timezone_helper')}</Field.Description>
</Field.Field>
</div>
<div>
<Button size="sm" onclick={handleSaveSession} loading={isSavingSession}>
{$t('course.navItem.lessons.session.save')}
</Button>
</div>
</Field.Set>
<Field.Separator />
{/if}
<Field.Set>
<Field.Legend>{$t('course.navItem.lessons.settings.progression.heading')}</Field.Legend>
<Field.Description>{$t('course.navItem.lessons.settings.progression.intro')}</Field.Description>
@@ -72,9 +234,36 @@
<Field.Group>
<Field.Field>
<Field.Label for="lesson-completion-policy">
{$t('course.navItem.lessons.completion_policy.label')}
</Field.Label>
<div class="flex items-center gap-1.5">
<Field.Label for="lesson-completion-policy">
{$t('course.navItem.lessons.completion_policy.label')}
</Field.Label>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger aria-label={$t('course.navItem.lessons.settings.progression.option_guide_heading')}>
<InfoIcon size={14} class="ui:text-muted-foreground" />
</Tooltip.Trigger>
<Tooltip.Content side="right" class="max-w-xs">
<ul class="list-disc space-y-1 pl-4 text-xs">
<li>
<span class="font-medium">{$t('course.navItem.lessons.completion_policy.manual_label')}:</span>
{$t('course.navItem.lessons.completion_policy.manual_description')}
</li>
<li>
<span class="font-medium"
>{$t('course.navItem.lessons.completion_policy.video_watch_label')}:</span
>
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</li>
<li>
<span class="font-medium">{$t('course.navItem.lessons.completion_policy.none_label')}:</span>
{$t('course.navItem.lessons.completion_policy.none_description')}
</li>
</ul>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
</div>
<Select.Root type="single" value={completionPolicy} onValueChange={handleCompletionPolicyChange}>
<Select.Trigger id="lesson-completion-policy" class="w-full max-w-xs">
{policyTriggerLabel(completionPolicy)}
@@ -108,30 +297,6 @@
</Select.Root>
</Field.Field>
<div class="space-y-2 text-sm">
<p class="font-medium">{$t('course.navItem.lessons.settings.progression.option_guide_heading')}</p>
<ul class="ui:text-muted-foreground list-disc space-y-1 pl-5">
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.manual_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.manual_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.video_watch_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.video_watch_description')}
</li>
<li>
<span class="ui:text-foreground font-medium">
{$t('course.navItem.lessons.completion_policy.none_label')}:
</span>
{$t('course.navItem.lessons.completion_policy.none_description')}
</li>
</ul>
</div>
{#if completionPolicy === 'video_watch'}
<Field.Field>
<InputField
@@ -181,3 +346,20 @@
</Field.Set>
</Field.Group>
</div>
<Dialog.Root bind:open={confirmOpen}>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title>{$t('course.navItem.lessons.session.confirm_title')}</Dialog.Title>
<Dialog.Description>{$t('course.navItem.lessons.session.confirm_body')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer>
<Button variant="outline" onclick={() => (confirmOpen = false)} disabled={isSavingSession}>
{$t('course.navItem.lessons.session.confirm_cancel')}
</Button>
<Button onclick={confirmSaveAndNotify} loading={isSavingSession}>
{$t('course.navItem.lessons.session.confirm_save')}
</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
@@ -0,0 +1,35 @@
<script lang="ts">
import { LiveSessionCard, type LiveSessionLabels } from '@cio/ui/custom/live-session-card';
import { t } from '$lib/utils/functions/translations';
interface Props {
title: string;
callUrl: string;
lessonAt: string;
timezone?: string | null;
durationMinutes?: number;
}
let { title, callUrl, lessonAt, timezone = null, durationMinutes = 60 }: Props = $props();
const labels: LiveSessionLabels = {
liveNow: t.get('course.navItem.lessons.session.live_now'),
liveSession: t.get('course.navItem.lessons.session.live_event'),
providerFallback: t.get('course.navItem.lessons.session.live_session'),
started: t.get('course.navItem.lessons.session.started'),
minAgo: t.get('course.navItem.lessons.session.min_ago'),
joinNow: t.get('course.navItem.lessons.session.join_now'),
copyLink: t.get('course.navItem.lessons.session.copy_link'),
copy: t.get('course.navItem.lessons.session.copy'),
addToCalendar: t.get('course.navItem.lessons.session.add_to_calendar'),
addGoogle: t.get('course.navItem.lessons.session.add_google'),
addOutlook: t.get('course.navItem.lessons.session.add_outlook'),
addOffice365: t.get('course.navItem.lessons.session.add_office365'),
addYahoo: t.get('course.navItem.lessons.session.add_yahoo'),
addApple: t.get('course.navItem.lessons.session.add_apple'),
startsIn: t.get('course.navItem.lessons.session.starts_in'),
sessionEnded: t.get('course.navItem.lessons.session.session_ended')
};
</script>
<LiveSessionCard {title} {callUrl} {lessonAt} {timezone} {durationMinutes} {labels} />