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:
@@ -350,13 +350,14 @@
|
||||
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
|
||||
}
|
||||
|
||||
/** When true, every question must have points > 0 before save */
|
||||
const requiresPositivePointsForAutoGrade = $derived(
|
||||
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
|
||||
);
|
||||
const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds));
|
||||
|
||||
/** When true, every question must have points > 0 before save (for meaningful auto-grading) */
|
||||
const requiresPositivePointsForAutoGrade = $derived(isFullyAutoGradable);
|
||||
|
||||
const teacherAutoGradeBadge = $derived.by(() => {
|
||||
if ($isOrgStudent || !isSelfPacedCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isExerciseFullyAutoGradable(activeQuestionTypeIds) ? ('auto' as const) : ('manual' as const);
|
||||
if ($isOrgStudent || isPublicCourse || activeQuestionTypeIds.length === 0) return null;
|
||||
return isFullyAutoGradable ? ('auto' as const) : ('manual' as const);
|
||||
});
|
||||
|
||||
async function scrollToExerciseElement(elementId: string) {
|
||||
@@ -498,6 +499,16 @@
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.reorder')}
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item
|
||||
disabled={!courseApi.course?.id || !exerciseId}
|
||||
onclick={() => {
|
||||
if (courseApi.course?.id && exerciseId) {
|
||||
exerciseApi.notifyStudents(courseApi.course.id, exerciseId);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{$t('course.navItem.lessons.exercises.all_exercises.notify.action')}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item
|
||||
class="text-red-600 focus:text-red-600 dark:text-red-400"
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
import { loadDraft, clearDraft } from '$features/course/utils/lesson-draft';
|
||||
import { getOrderedNavigableContent } from '$features/course/utils/content';
|
||||
import StudentContentLockedNotice from '$features/course/components/student-content-locked-notice.svelte';
|
||||
import LiveSessionCard from '$features/course/components/lesson/live-session-card.svelte';
|
||||
|
||||
interface Props {
|
||||
courseId: string;
|
||||
@@ -94,6 +95,7 @@
|
||||
const isLessonUnlocked = $derived(!isTeacherLocked && !isProgressionLocked);
|
||||
const lessonSlug = $derived(lessonApi.lesson?.slug ?? '');
|
||||
const isPublicCourse = $derived(courseApi.course?.type === 'PUBLIC');
|
||||
const isLiveSessionLesson = $derived(Boolean(lessonApi.lesson?.callUrl && lessonApi.lesson?.lessonAt));
|
||||
|
||||
function setModeQueryParam(value: (typeof MODES)[keyof typeof MODES]) {
|
||||
const params = new SvelteURLSearchParams($page.url.searchParams);
|
||||
@@ -445,9 +447,18 @@
|
||||
|
||||
<Page.Body>
|
||||
{#snippet child()}
|
||||
<div
|
||||
class={`overflow-x-hidden py-6 pb-20 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}
|
||||
>
|
||||
<div class={`overflow-x-hidden pb-6 ${mode === MODES.edit ? 'lg:w-full xl:w-11/12' : 'mx-auto w-full max-w-3xl'}`}>
|
||||
{#if isLiveSessionLesson && mode === MODES.view && !($isOrgStudent && !isLessonUnlocked)}
|
||||
<div class="mb-4">
|
||||
<LiveSessionCard
|
||||
title={lessonTitle}
|
||||
callUrl={lessonApi.lesson?.callUrl ?? ''}
|
||||
lessonAt={lessonApi.lesson?.lessonAt ?? ''}
|
||||
timezone={courseApi.course?.metadata?.sessionTimezone}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $isOrgStudent && !isLessonUnlocked}
|
||||
<StudentContentLockedNotice
|
||||
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { Label } from '@cio/ui/base/label';
|
||||
import { Switch } from '@cio/ui/base/switch';
|
||||
import * as RadioGroup from '@cio/ui/base/radio-group';
|
||||
import * as Select from '@cio/ui/base/select';
|
||||
import RotateCcwIcon from '@lucide/svelte/icons/rotate-ccw';
|
||||
import ArrowUpRightIcon from '@lucide/svelte/icons/arrow-up-right';
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
@@ -15,7 +16,7 @@
|
||||
import { TextareaField } from '@cio/ui/custom/textarea-field';
|
||||
import { InputField } from '@cio/ui/custom/input-field';
|
||||
import * as Field from '@cio/ui/base/field';
|
||||
import { UpgradeBanner, UnsavedChanges, UploadWidget } from '$features/ui';
|
||||
import { UpgradeBanner, UnsavedChanges, UploadWidget, TextEditor } from '$features/ui';
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
|
||||
import { settings } from '$features/course/utils/settings-store';
|
||||
@@ -159,7 +160,8 @@
|
||||
lessonDownload: $settings.lessonDownload,
|
||||
allowNewStudent: $settings.allowNewStudents ?? false,
|
||||
isContentGroupingEnabled: $settings.isContentGroupingEnabled,
|
||||
progressionMode: $settings.progressionMode
|
||||
progressionMode: $settings.progressionMode,
|
||||
welcomeEmailMessage: $settings.welcomeEmailMessage?.trim() ? $settings.welcomeEmailMessage : null
|
||||
} as NonNullable<Course['metadata']>;
|
||||
|
||||
const updatedCourse = {
|
||||
@@ -224,7 +226,8 @@
|
||||
allowNewStudents: !!course.metadata?.allowNewStudent,
|
||||
isContentGroupingEnabled: course.metadata?.isContentGroupingEnabled ?? true,
|
||||
progressionMode: course.metadata?.progressionMode ?? 'free',
|
||||
callout: normalizeCallout(course.callout)
|
||||
callout: normalizeCallout(course.callout),
|
||||
welcomeEmailMessage: course.metadata?.welcomeEmailMessage ?? ''
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -350,7 +353,7 @@
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button onclick={widgetControl}>
|
||||
<Button variant="secondary" onclick={widgetControl}>
|
||||
{$t('course.navItem.settings.replace')}
|
||||
</Button>
|
||||
<Button variant="outline" onclick={deleteBannerImage}>
|
||||
@@ -443,6 +446,83 @@
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.type')}</Field.Legend>
|
||||
<Field.Description>
|
||||
{$t('course.navItem.settings.course_type_desc')}
|
||||
<a
|
||||
href="https://classroomio.com/docs/course-types"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ui:text-primary underline"
|
||||
>
|
||||
{$t('course.navItem.settings.course_type_learn_more')}
|
||||
</a>
|
||||
</Field.Description>
|
||||
<Field.Field>
|
||||
<Select.Root
|
||||
type="single"
|
||||
value={$settings.type}
|
||||
onValueChange={(value) => {
|
||||
if (!value) return;
|
||||
$settings.type = value as TCourseType;
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
<Select.Trigger class="w-full">
|
||||
{$t(`course.navItem.settings.${$settings.type.toLowerCase()}`)}
|
||||
</Select.Trigger>
|
||||
<Select.Content>
|
||||
<Select.Group>
|
||||
<Select.Item value="SELF_PACED" label={$t('course.navItem.settings.self_paced')}>
|
||||
{$t('course.navItem.settings.self_paced')}
|
||||
</Select.Item>
|
||||
<Select.Item value="LIVE_CLASS" label={$t('course.navItem.settings.live_class')}>
|
||||
{$t('course.navItem.settings.live_class')}
|
||||
</Select.Item>
|
||||
<Select.Item value="COMPLIANCE" label={$t('course.navItem.settings.compliance')}>
|
||||
{$t('course.navItem.settings.compliance')}
|
||||
</Select.Item>
|
||||
<Select.Item value="PUBLIC" label={$t('course.navItem.settings.public')}>
|
||||
{$t('course.navItem.settings.public')}
|
||||
</Select.Item>
|
||||
</Select.Group>
|
||||
</Select.Content>
|
||||
</Select.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.errors.type}
|
||||
<div
|
||||
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
|
||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.welcome_email.title')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.welcome_email.description')}</Field.Description>
|
||||
<div class="w-full max-w-md">
|
||||
<TextEditor
|
||||
content={$settings.welcomeEmailMessage}
|
||||
placeholder={$t('course.navItem.settings.welcome_email.placeholder')}
|
||||
class="w-full"
|
||||
editorClass="h-auto! max-h-[200px] min-h-[120px]"
|
||||
onChange={(text) => {
|
||||
$settings.welcomeEmailMessage = text;
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.tags.title')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.tags.description')}</Field.Description>
|
||||
@@ -592,50 +672,6 @@
|
||||
</Field.Field>
|
||||
</Field.Set>
|
||||
|
||||
<Field.Separator />
|
||||
|
||||
<Field.Set>
|
||||
<Field.Legend>{$t('course.navItem.settings.type')}</Field.Legend>
|
||||
<Field.Description>{$t('course.navItem.settings.course_type_desc')}</Field.Description>
|
||||
<Field.Field>
|
||||
<RadioGroup.Root
|
||||
value={$settings.type}
|
||||
onValueChange={(value) => {
|
||||
$settings.type = value as TCourseType;
|
||||
if (hasUnsavedChanges) return;
|
||||
hasUnsavedChanges = true;
|
||||
}}
|
||||
>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'SELF_PACED' as TCourseType} id="self-paced" />
|
||||
<Label for="self-paced">{$t('course.navItem.settings.self_paced')}</Label>
|
||||
</div>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'LIVE_CLASS' as TCourseType} id="live-class" />
|
||||
<Label for="live-class">{$t('course.navItem.settings.live_class')}</Label>
|
||||
</div>
|
||||
<div class="mb-3 flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'COMPLIANCE' as TCourseType} id="compliance-course" />
|
||||
<Label for="compliance-course">{$t('course.navItem.settings.compliance')}</Label>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<RadioGroup.Item value={'PUBLIC' as TCourseType} id="public-course" />
|
||||
<Label for="public-course">{$t('course.navItem.settings.public')}</Label>
|
||||
</div>
|
||||
</RadioGroup.Root>
|
||||
</Field.Field>
|
||||
|
||||
{#if courseApi.errors.type}
|
||||
<div
|
||||
class="ui:mt-2 ui:rounded-md ui:border ui:border-destructive/30 ui:bg-destructive/5 ui:p-3 ui:text-sm ui:text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div class="ui:font-medium">{$t('course.navItem.settings.convert_to_public_blocked')}</div>
|
||||
<p class="ui:mt-1 ui:text-destructive/90">{courseApi.errors.type}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</Field.Set>
|
||||
|
||||
{#if $settings.type === 'PUBLIC' && $settings.callout}
|
||||
<Field.Separator />
|
||||
|
||||
|
||||
Reference in New Issue
Block a user