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
@@ -140,6 +140,7 @@
{/each}
</select>
<Field.Description>{$t('course.certification.final_exercise_helper')}</Field.Description>
<Field.Description>{$t('course.certification.final_exercise_multiple_attempts_note')}</Field.Description>
</Field.Field>
{#if courseApi.course?.certificate?.requiredExerciseId}
@@ -4,21 +4,34 @@
import { resolve } from '$app/paths';
import * as Dialog from '@cio/ui/base/dialog';
import { Button } from '@cio/ui/base/button';
import { Spinner } from '@cio/ui/base/spinner';
import { Confetti } from '$features/ui';
import { setConfetti } from '$features/ui/confetti/store';
import { ContentType } from '@cio/utils/constants/content';
import { courseCompletionModal, closeCourseCompletionModal } from '$features/course/store/course-completion-modal';
import { getContentRoute } from '$features/course/utils/content';
import { formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { t } from '$lib/utils/functions/translations';
let open = $state(false);
let activeCourseId = $state('');
let activeStep = $state<'checking' | 'eligible' | 'not-eligible'>('checking');
let activeEvaluation = $state<Record<string, unknown> | null>(null);
let activeExerciseId = $state<string | undefined>(undefined);
onMount(() => {
return courseCompletionModal.subscribe((v) => {
if (v?.open && v.courseId) {
activeCourseId = v.courseId;
activeStep = v.step;
activeEvaluation = v.evaluation as Record<string, unknown> | null;
activeExerciseId = v.exerciseId;
open = true;
setConfetti(true);
setTimeout(() => setConfetti(false), 5500);
if (v.step === 'eligible' && !v.evaluation?.certificateEarnedAt) {
setConfetti(true);
setTimeout(() => setConfetti(false), 5500);
}
} else {
open = false;
}
@@ -37,6 +50,24 @@
onOpenChange(false);
goto(resolve(`/courses/${activeCourseId}/certificates`, {}));
}
function goToExercise() {
if (!activeCourseId || !activeExerciseId) return;
onOpenChange(false);
const path = getContentRoute(activeCourseId, { id: activeExerciseId, type: ContentType.Exercise });
if (path) {
goto(resolve(path, {}));
}
}
function formatSingleBlocker() {
if (!activeEvaluation) return '';
const blockers = (activeEvaluation as Record<string, unknown>).blockers as
| Array<Record<string, unknown>>
| undefined;
if (!blockers || blockers.length === 0) return '';
return formatBlockerMessage(blockers[0] as Parameters<typeof formatBlockerMessage>[0]);
}
</script>
<div class="pointer-events-none fixed inset-0 z-100">
@@ -45,13 +76,33 @@
<Dialog.Root {open} {onOpenChange}>
<Dialog.Content class="ui:max-w-md">
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer>
{#if activeStep === 'checking'}
<div class="flex flex-col items-center justify-center gap-4 py-8">
<Spinner class="ui:size-8" />
<p class="ui:text-muted-foreground text-sm">{$t('course.completion.modal.checking')}</p>
</div>
{:else if activeStep === 'eligible'}
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.title')}</Dialog.Title>
<Dialog.Description>{$t('course.completion.modal.subtitle')}</Dialog.Description>
</Dialog.Header>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
<Button onclick={goToCertificate}>{$t('course.completion.modal.view_certificate')}</Button>
</Dialog.Footer>
{:else if activeStep === 'not-eligible'}
<Dialog.Header>
<Dialog.Title>{$t('course.completion.modal.not_eligible_title')}</Dialog.Title>
</Dialog.Header>
<div class="ui:text-muted-foreground px-6 pb-4 text-center text-sm">
<p>{formatSingleBlocker()}</p>
</div>
<Dialog.Footer class="ui:gap-2">
<Button variant="outline" onclick={() => onOpenChange(false)}>{$t('course.completion.modal.later')}</Button>
{#if activeExerciseId}
<Button onclick={goToExercise}>{$t('course.completion.modal.retake_exercise')}</Button>
{/if}
</Dialog.Footer>
{/if}
</Dialog.Content>
</Dialog.Root>
@@ -11,7 +11,7 @@
import { snackbar } from '$features/ui/snackbar/store';
import { classroomio } from '$lib/utils/services/api';
import type { CertificationEvaluationData } from '$features/course/utils/types';
import { normalizeCertificateIssuedAt } from '$features/course/utils/certificate-utils';
import { normalizeCertificateIssuedAt, formatBlockerMessage } from '$features/course/utils/certificate-utils';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
let isLoading = $state(false);
@@ -21,41 +21,6 @@
let courseId = $derived(courseApi.course?.id ?? '');
function formatBlockerMessage(blocker: CertificationEvaluationData['blockers'][number]): string {
const p = blocker.params ?? {};
switch (blocker.code) {
case 'CERT_PROGRESS':
return t.get('course.certification.blocker_progress', {
current: Number(p.current ?? 0),
required: Number(p.required ?? 0)
});
case 'CERT_DEADLINE_PASSED':
return t.get('course.certification.blocker_deadline');
case 'CERT_NO_CONTENT':
return t.get('course.certification.blocker_no_content');
case 'CERT_FINAL_EXERCISE_NOT_SUBMITTED':
return t.get('course.certification.blocker_final_not_submitted', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_PENDING_GRADE':
return t.get('course.certification.blocker_final_pending', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
case 'CERT_FINAL_EXERCISE_SCORE':
return t.get('course.certification.blocker_final_score', {
exerciseTitle: String(p.exerciseTitle ?? ''),
bestPercent: Number(p.bestPercent ?? 0),
requiredPercent: Number(p.requiredPercent ?? 0)
});
case 'CERT_FINAL_EXERCISE_MISCONFIGURED':
return t.get('course.certification.blocker_final_misconfigured', {
exerciseTitle: String(p.exerciseTitle ?? '')
});
default:
return blocker.code;
}
}
function buildBody() {
return {
studentName: $profile.fullname || 'Recipient',
@@ -3,6 +3,7 @@
import * as Sidebar from '@cio/ui/base/sidebar';
import EyeIcon from '@lucide/svelte/icons/eye';
import { Button } from '@cio/ui/base/button';
import { Waves } from '@cio/ui/custom/animation';
import { page } from '$app/state';
import { currentOrg, currentOrgDomain } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
@@ -58,9 +59,22 @@
<span class="grow"></span>
<Button variant="outline" size="sm" onclick={toggleAiAssistant}>
<SparklesIcon size={14} />
{$t('course.navItems.nav_ai_assistant')}
<Button
size="sm"
onclick={toggleAiAssistant}
class="ui:bg-primary ui:text-primary-foreground relative overflow-hidden border-0"
>
<Waves
lineColor="rgba(255,255,255,0.55)"
xGap={8}
yGap={12}
waveAmpX={18}
waveAmpY={9}
waveSpeedX={0.04}
waveSpeedY={0.02}
/>
<SparklesIcon size={14} class="relative z-10" />
<span class="relative z-10">{$t('course.navItems.nav_ai_assistant')}</span>
</Button>
{#if !$isStudentExperience}
@@ -53,7 +53,7 @@
shouldDeleteExercise?: boolean;
exerciseId: string;
goBack?: () => void;
/** Self-paced + all auto-gradable question types: enforce points &gt; 0 */
/** All auto-gradable question types (any course): enforce points &gt; 0 for scoring */
requiresPositivePointsForAutoGrade?: boolean;
selfPacedCourse?: boolean;
/** Public course restricts picker to auto-gradable question types only. */
@@ -40,7 +40,11 @@
import { ContentType } from '@cio/utils/constants/content';
import { getOrderedNavigableContent } from '$features/course/utils/content';
import { isOrgStudent } from '$lib/utils/store/app';
import { openCourseCompletionModal } from '$features/course/store/course-completion-modal';
import {
openCourseCompletionModal,
updateCourseCompletionModal,
closeCourseCompletionModal
} from '$features/course/store/course-completion-modal';
import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils';
import { toExerciseQuestionModel } from './question-type-utils';
import { getExerciseQuestionLabels } from './question-labels';
@@ -152,7 +156,19 @@
$isOrgStudent && allContentItems.length > 0 && allContentItems.every((item) => item.isComplete);
if (allComplete && courseApi.course?.id) {
const requiredExerciseId = courseApi.course?.certificate?.requiredExerciseId ?? undefined;
openCourseCompletionModal(courseApi.course.id);
const certRes = await courseApi.getCertificationEvaluation(courseApi.course.id);
if (certRes?.data) {
if (certRes.data.isNewCompletion) {
updateCourseCompletionModal(courseApi.course.id, 'eligible', certRes.data, requiredExerciseId);
} else {
updateCourseCompletionModal(courseApi.course.id, 'not-eligible', certRes.data, requiredExerciseId);
}
} else {
closeCourseCompletionModal();
}
}
}
@@ -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} />
@@ -12,6 +12,7 @@
import { CircleCheckIcon } from '$features/ui/icons';
import { t } from '$lib/utils/functions/translations';
import { IconButton } from '@cio/ui/custom/icon-button';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import CourseContentIcon from '$features/course/components/course-content-icon.svelte';
interface Props {
@@ -29,6 +30,16 @@
const canOpenContentModal = $derived(Boolean(onOpenContentModal) && !isStudent);
</script>
{#snippet liveSessionDot()}
<span
class="shrink-0"
title={$t('course.navItem.lessons.session.live_indicator')}
aria-label={$t('course.navItem.lessons.session.live_indicator')}
>
<RadioIcon size={14} color="#ef4444" />
</span>
{/snippet}
<Sidebar.MenuSub class={className}>
{#if contentData.grouped}
{#each contentData.sections as section (section.id)}
@@ -74,7 +85,7 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && isContentLocked}
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -97,7 +108,9 @@
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if isContentLocked}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}
@@ -124,7 +137,7 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && isContentLocked}
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -145,7 +158,9 @@
<CircleCheckIcon size={16} filled />
</span>
{/if}
{#if isContentLocked}
{#if contentItem.type === ContentType.Lesson && contentItem.callUrl}
{@render liveSessionDot()}
{:else if isContentLocked || isLockedForStudent}
<span
class="shrink-0"
title={$t('course.navItem.lessons.add_lesson.lock')}