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:
@@ -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)}
|
||||
|
||||
+17
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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)}
|
||||
|
||||
+210
-28
@@ -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} />
|
||||
Reference in New Issue
Block a user