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
*/
@@ -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')}
@@ -350,13 +350,14 @@
return typeIds.length > 0 && typeIds.every((id) => isAutoGradableQuestionType(getQuestionTypeKeyFromId(id)));
}
/** When true, every question must have points &gt; 0 before save */
const requiresPositivePointsForAutoGrade = $derived(
isSelfPacedCourse && isExerciseFullyAutoGradable(activeQuestionTypeIds)
);
const isFullyAutoGradable = $derived(isExerciseFullyAutoGradable(activeQuestionTypeIds));
/** When true, every question must have points &gt; 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 />
@@ -1,14 +1,30 @@
import { writable } from 'svelte/store';
import type { CertificationEvaluationData } from '$features/course/utils/types';
export type ModalStep = 'checking' | 'eligible' | 'not-eligible';
export type CourseCompletionModalState = {
open: boolean;
courseId: string;
step: ModalStep;
evaluation: CertificationEvaluationData | null;
/** Exercise to retake when not-eligible */
exerciseId?: string;
};
export const courseCompletionModal = writable<CourseCompletionModalState | null>(null);
export function openCourseCompletionModal(courseId: string) {
courseCompletionModal.set({ open: true, courseId });
courseCompletionModal.set({ open: true, courseId, step: 'checking', evaluation: null });
}
export function updateCourseCompletionModal(
courseId: string,
step: ModalStep,
evaluation: CertificationEvaluationData,
exerciseId?: string
) {
courseCompletionModal.set({ open: true, courseId, step, evaluation, exerciseId });
}
export function closeCourseCompletionModal() {
@@ -1,5 +1,6 @@
import { resolveTemplateId } from '@cio/certificates';
import { DEFAULT_CERTIFICATE_THEME_ID, type CertificateThemeId } from './constants';
import { t } from '$lib/utils/functions/translations';
/**
* Resolves a stored theme/template id. Maps legacy 6-theme ids onto the new
@@ -12,6 +13,46 @@ export function parseCertificateThemeId(raw: string | null | undefined): Certifi
return resolveTemplateId(raw);
}
export type BlockerData = {
code: string;
params?: Record<string, string | number>;
};
export function formatBlockerMessage(blocker: BlockerData): 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;
}
}
/** Normalizes DB/API timestamps to an ISO-8601 UTC string accepted by Zod `.datetime()`. */
export function normalizeCertificateIssuedAt(value: string | null | undefined): string {
if (!value) return new Date().toISOString();
@@ -15,6 +15,7 @@ type CourseSettings = {
isContentGroupingEnabled: boolean;
progressionMode: 'free' | 'sequential';
callout: TCourseCallout | null;
welcomeEmailMessage: string;
};
export const settings = writable<CourseSettings>({
@@ -34,5 +35,6 @@ export const settings = writable<CourseSettings>({
isPublished: false,
isContentGroupingEnabled: true,
progressionMode: 'free',
callout: null
callout: null,
welcomeEmailMessage: ''
});
@@ -0,0 +1,56 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
import { RadioIcon } from '@cio/ui/custom/moving-icons';
import ClockIcon from '@lucide/svelte/icons/clock';
import VideoIcon from '@lucide/svelte/icons/video';
import { t } from '$lib/utils/functions/translations';
interface SessionItem {
lessonId: string;
courseTitle: string;
lessonTitle: string;
callUrl: string;
lessonAt: string;
timezone?: string | null;
}
let { sessions }: { sessions: SessionItem[] } = $props();
function dateLabel(iso: string, tz?: string | null): string {
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: tz || Intl.DateTimeFormat().resolvedOptions().timeZone,
dateStyle: 'medium',
timeStyle: 'short'
}).format(new Date(iso));
} catch {
return new Date(iso).toUTCString();
}
}
</script>
{#if sessions.length}
<section class="ui:bg-card ui:text-card-foreground ui:border-primary/30 rounded border p-4">
<h3 class="mb-3 flex items-center gap-2 text-sm font-semibold">
<RadioIcon size={16} color="#ef4444" />
{$t('dashboard.upcoming_sessions_heading')}
</h3>
<ul class="space-y-3">
{#each sessions as session (session.lessonId)}
<li class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="truncate text-sm font-medium">{session.lessonTitle}</p>
<p class="ui:text-muted-foreground flex items-center gap-1.5 truncate text-xs">
<ClockIcon size={12} class="shrink-0" />
{dateLabel(session.lessonAt, session.timezone)} · {session.courseTitle}
</p>
</div>
<a href={session.callUrl} target="_blank" rel="noreferrer" class="shrink-0">
<Button size="sm"><VideoIcon size={14} />{$t('course.navItem.lessons.session.join')}</Button>
</a>
</li>
{/each}
</ul>
</section>
{/if}
@@ -19,12 +19,14 @@
import { coursesApi } from '$features/course/api';
import * as ResourceListRow from '@cio/ui/custom/resource-list-row';
import { CourseListRow } from '$features/course/components';
import UpcomingSessionsCard from '$features/lms/components/upcoming-sessions-card.svelte';
import CoursePublicBadge from '$features/course/components/course-public-badge.svelte';
import CoursePreviewModal from '$features/lms/components/course-preview-modal.svelte';
import type { RecommendedCourses } from '$features/course/types';
import { IconButton } from '@cio/ui/custom/icon-button';
import ExternalLinkIcon from '@lucide/svelte/icons/external-link';
import CopyIcon from '@lucide/svelte/icons/copy';
import VideoIcon from '@lucide/svelte/icons/video';
import { copyPublicCoursePageUrl, openCoursePreview } from '$features/course/utils/course-preview';
import {
getStudentCourseComplianceDate,
@@ -59,6 +61,27 @@
let inProgressCourses = $derived(coursesApi.enrolledCourses.filter((course) => !isStudentCourseComplete(course)));
let highlightedCourses = $derived.by(() => getHighlightedCourses(inProgressCourses));
let currentCourse = $derived(highlightedCourses[0] ?? coursesApi.enrolledCourses[0] ?? null);
let shouldShowCertificateHero = $derived.by(() => {
if (!currentCourse || coursesApi.enrolledCourses.length !== 1) {
return false;
}
return isStudentCourseComplete(currentCourse) && currentCourse.certificateEarnedAt != null;
});
let upcomingSessions = $derived(
coursesApi.enrolledCourses
.filter((course) => course.type === 'LIVE_CLASS' && course.upcomingSession)
.map((course) => ({
lessonId: course.upcomingSession!.lessonId,
courseTitle: course.title,
lessonTitle: course.upcomingSession!.lessonTitle,
callUrl: course.upcomingSession!.callUrl,
lessonAt: course.upcomingSession!.lessonAt,
timezone: course.upcomingSession!.sessionTimezone
}))
.sort((a, b) => new Date(a.lessonAt).getTime() - new Date(b.lessonAt).getTime())
);
$effect(() => {
if (!$profile.id || !$currentOrg.id) return;
@@ -127,6 +150,12 @@
goto(`/courses/${id}/lessons?next=true`);
}
function gotoCourseCertificates(id: string | undefined) {
if (!id) return;
goto(`/courses/${id}/certificates`);
}
function showPublishedPublicCourseLinks(course: EnrolledCourse | null) {
if (!course || course.type !== 'PUBLIC') {
return false;
@@ -167,16 +196,28 @@
</script>
<div class="space-y-6 pb-8">
{#if upcomingSessions.length}
<UpcomingSessionsCard sessions={upcomingSessions} />
{/if}
<BlurFade delay={0.05} once>
<section class="ui:border-primary/20 ui:bg-primary/10 rounded border p-4 md:p-6">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="max-w-xl space-y-2">
<p class="ui:text-primary text-xs font-semibold tracking-[0.18em] uppercase">
{$t('dashboard.pick_up_where_you_left_off')}
{#if shouldShowCertificateHero}
{$t('dashboard.get_your_certificate')}
{:else}
{$t('dashboard.pick_up_where_you_left_off')}
{/if}
</p>
<h2 class="text-xl font-semibold tracking-tight">
{#if currentCourse}
{$t('dashboard.course_awaits_you', { title: currentCourse.title })}
{#if shouldShowCertificateHero}
{currentCourse.title}
{:else}
{$t('dashboard.course_awaits_you', { title: currentCourse.title })}
{/if}
{:else}
{$t('dashboard.learning_awaits_you')}
{/if}
@@ -205,10 +246,19 @@
<Button
variant="outline"
class="shrink-0"
onclick={() => (currentCourse ? gotoCourse(currentCourse.id) : goto('/lms/explore'))}
onclick={() =>
currentCourse
? shouldShowCertificateHero
? gotoCourseCertificates(currentCourse.id)
: gotoCourse(currentCourse.id)
: goto('/lms/explore')}
>
{#if currentCourse}
{$t('dashboard.continue_learning')}
{#if shouldShowCertificateHero}
{$t('dashboard.my_certificate')}
{:else}
{$t('dashboard.continue_learning')}
{/if}
{:else}
{$t('dashboard.view_courses')}
{/if}
@@ -337,6 +387,11 @@
<CopyIcon class="size-4" />
</IconButton>
{/if}
{#if course.type === 'LIVE_CLASS' && course.upcomingSession?.callUrl}
<a href={course.upcomingSession.callUrl} target="_blank" rel="noreferrer" class="shrink-0">
<Button><VideoIcon class="size-4" />{$t('course.navItem.lessons.session.join')}</Button>
</a>
{/if}
<Button variant="outline" class="shrink-0" onclick={() => gotoCourse(course.id)}>
{$t('dashboard.continue')}
</Button>
@@ -2,3 +2,4 @@ export { default as LanguagePicker } from './language-picker.svelte';
export { default as AuthGeneral } from './auth-general.svelte';
export { default as AuthSso } from './auth-sso.svelte';
export { default as AuthTokenAuth } from './auth-token-auth.svelte';
export { default as OrgSettingsInlineTabs } from './org-settings-inline-tabs.svelte';
@@ -0,0 +1,52 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { currentOrgPath } from '$lib/utils/store/org';
import { t } from '$lib/utils/functions/translations';
const orgSettingsTabs = [
{ value: 'general', href: '/settings/org', label: 'settings.auth.tabs.general' },
{
value: 'domains',
href: '/settings/domains',
label: 'settings.organization.organization_profile.custom_domain.heading'
},
{ value: 'teams', href: '/settings/teams', label: 'settings.organization.organization_profile.team.heading' },
{ value: 'customize-lms', href: '/settings/customize-lms', label: 'settings.tabs.customize_lms_tab' }
] as const;
function getCurrentTab(pathname: string) {
if (pathname.endsWith('/settings/org')) return 'general';
if (pathname.endsWith('/settings/customize-lms')) return 'customize-lms';
if (pathname.endsWith('/settings/domains')) return 'domains';
if (pathname.endsWith('/settings/teams')) return 'teams';
return null;
}
let currentTab = $derived(getCurrentTab(page.url.pathname));
function handleTabChange(value: string) {
const nextTab = orgSettingsTabs.find((tab) => tab.value === value);
if (!nextTab || value === currentTab) return;
goto($currentOrgPath + nextTab.href);
}
</script>
{#if currentTab}
<div class="mb-6 px-2">
<UnderlineTabs.Root value={currentTab} onValueChange={(event) => handleTabChange(event)}>
<UnderlineTabs.List class="flex flex-wrap gap-2 border-b">
{#each orgSettingsTabs as tab}
<UnderlineTabs.Trigger value={tab.value}>
{$t(tab.label)}
</UnderlineTabs.Trigger>
{/each}
</UnderlineTabs.List>
</UnderlineTabs.Root>
</div>
{/if}
@@ -1,17 +1,14 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ZapIcon from '@lucide/svelte/icons/zap';
import PlusIcon from '@lucide/svelte/icons/plus';
import debounce from 'lodash/debounce';
import ColorPicker from 'svelte-awesome-color-picker';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org';
import { currentOrg } from '$lib/utils/store/org';
import { setTheme } from '$lib/utils/functions/theme';
import { orgApi } from '$features/org/api/org.svelte';
import { Input } from '@cio/ui/base/input';
import { Button } from '@cio/ui/base/button';
import { UploadImage, UnsavedChanges } from '$features/ui';
import * as Field from '@cio/ui/base/field';
@@ -63,9 +60,6 @@
}
}
function gotoSettings(pathname: string) {
goto(`${$currentOrgPath}/settings${pathname}`);
}
let isCustomTheme = $derived($currentOrg?.theme?.includes('#'));
let hex = $derived($currentOrg.theme?.includes('#') ? $currentOrg.theme : undefined);
</script>
@@ -171,51 +165,6 @@
</div>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.customize_lms.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.customize_lms.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/customize-lms')}>
{$t('settings.organization.organization_profile.customize_lms.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.custom_domain.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.custom_domain.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/domains')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.custom_domain.button')}
</Button>
</Field.Field>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('settings.organization.organization_profile.team.heading')}</Field.Legend>
<Field.Description>{$t('settings.organization.organization_profile.team.sub_heading')}</Field.Description>
<Field.Description>{$t('settings.organization.organization_profile.team.body')}</Field.Description>
<Field.Field orientation="horizontal">
<Button variant="outline" onclick={() => gotoSettings('/teams')}>
{#if $isFreePlan}
<ZapIcon size={16} class="filled" />
{/if}
{$t('settings.organization.organization_profile.team.button')}
</Button>
</Field.Field>
</Field.Set>
</Field.Group>
<style>
@@ -1,7 +1,7 @@
<script lang="ts">
import * as Select from '@cio/ui/base/select';
import { Spinner } from '@cio/ui/base/spinner';
import { Switch } from '@cio/ui/base/switch';
import CopyIcon from '@lucide/svelte/icons/copy';
import { profile } from '$lib/utils/store/user';
import { isFreePlan, currentOrg } from '$lib/utils/store/org';
@@ -23,32 +23,23 @@
let errorMessage = $state('');
let role = $state(ROLE.TUTOR.toString());
let isRemoving: number | null = $state(null);
let linkRole = $state(ROLE.TUTOR.toString());
let copiedLink = $state(false);
function buildLinkInviteUrl(token: string): string {
return `${window.location.origin}/invite/link/${encodeURIComponent(token)}`;
}
async function onGenerateLink() {
async function onCopyInviteLink() {
if ($isFreePlan) {
snackbar.error('upgrade.required');
return;
}
await orgApi.generateLinkInvite(parseInt(linkRole));
}
await orgApi.generateLinkInvite(parseInt(role));
const linkInviteToken = orgApi.linkInvite?.token;
async function onCopyLink() {
if (!orgApi.linkInvite?.token) return;
if (!linkInviteToken) return;
await navigator.clipboard.writeText(buildLinkInviteUrl(orgApi.linkInvite.token));
copiedLink = true;
setTimeout(() => (copiedLink = false), 2000);
}
async function onToggleLinkInvite(active: boolean) {
await orgApi.toggleLinkInvite(!active);
await navigator.clipboard.writeText(buildLinkInviteUrl(linkInviteToken));
}
async function onSendInvite() {
@@ -108,20 +99,18 @@
if (!$currentOrg) return;
orgApi.getOrgTeam();
orgApi.getLinkInvite();
});
</script>
<UpgradeBanner>{$t('upgrade.team')}</UpgradeBanner>
<Field.Group class="w-full max-w-md! px-2">
<Field.Group class="w-full max-w-3xl! px-2">
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.add')}</Field.Legend>
<Field.Description class="mb-5">{$t('course.navItem.people.teams.add_team')}</Field.Description>
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.invite')}</Field.Label>
<div class="flex flex-col gap-4 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem_auto] lg:items-start">
<Field.Field class="min-w-0">
<Input
placeholder={$t('course.navItem.people.teams.placeholder')}
bind:value={emailsStr}
@@ -133,10 +122,9 @@
{/if}
</Field.Field>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<div class="w-full lg:w-64">
<Select.Root type="single" bind:value={role} disabled={$isFreePlan}>
<Select.Trigger id="invite-role" class="w-40">
<Select.Trigger id="invite-role" class="ui:w-full ui:max-w-none">
<p>{role ? $t(ROLE_LABEL[role]) : $t('course.navItem.people.teams.select_role')}</p>
</Select.Trigger>
<Select.Content>
@@ -144,80 +132,31 @@
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Button
variant="default"
onclick={onSendInvite}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.send_invite')}
</Button>
</Field.Group>
</Field.Set>
<Field.Separator />
<Field.Set>
<Field.Legend>{$t('course.navItem.people.teams.link_invite.heading')}</Field.Legend>
<Field.Description class="mb-4">{$t('course.navItem.people.teams.link_invite.description')}</Field.Description>
{#if !orgApi.linkInvite}
<Field.Group>
<Field.Field>
<Field.Label>{$t('course.navItem.people.teams.role')}</Field.Label>
<Select.Root type="single" bind:value={linkRole} disabled={$isFreePlan}>
<Select.Trigger class="w-40">
<p>{linkRole ? $t(ROLE_LABEL[linkRole]) : $t('course.navItem.people.teams.link_invite.select_role')}</p>
</Select.Trigger>
<Select.Content>
<Select.Item value={ROLE.ADMIN.toString()}>{$t(ROLE_LABEL[ROLE.ADMIN])}</Select.Item>
<Select.Item value={ROLE.TUTOR.toString()}>{$t(ROLE_LABEL[ROLE.TUTOR])}</Select.Item>
</Select.Content>
</Select.Root>
</Field.Field>
<Button
variant="default"
onclick={onGenerateLink}
variant="link"
size="sm"
class="h-auto justify-start"
onclick={onCopyInviteLink}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.link_invite.generate')}
<CopyIcon size={14} />
{$t('course.navItem.people.teams.link_invite.copy_invite_link')}
</Button>
</Field.Group>
{:else}
<Field.Group>
<Field.Field>
<div class="flex items-center gap-2">
<Input
value={buildLinkInviteUrl(orgApi.linkInvite.token)}
readonly
class="w-full font-mono text-xs {orgApi.linkInvite.isRevoked ? 'opacity-50' : ''}"
/>
<Button variant="outline" size="sm" onclick={onCopyLink} disabled={orgApi.linkInvite.isRevoked}>
{copiedLink
? $t('course.navItem.people.teams.link_invite.copied')
: $t('course.navItem.people.teams.link_invite.copy')}
</Button>
</div>
</Field.Field>
</div>
{#if orgApi.linkInvite.isRevoked}
<p class="text-sm text-amber-600">{$t('course.navItem.people.teams.link_invite.disabled_notice')}</p>
{/if}
<Field.Field orientation="horizontal" class="mt-2">
<Switch
checked={!orgApi.linkInvite.isRevoked}
onCheckedChange={onToggleLinkInvite}
disabled={orgApi.isLoading || $isFreePlan}
/>
<Field.Label>{$t('course.navItem.people.teams.link_invite.toggle_label')}</Field.Label>
</Field.Field>
</Field.Group>
{/if}
<div class="lg:self-start">
<Button
variant="default"
onclick={onSendInvite}
loading={orgApi.isLoading}
disabled={orgApi.isLoading || $isFreePlan}
>
{$t('course.navItem.people.teams.send_invite')}
</Button>
</div>
</div>
</Field.Set>
<Field.Separator />
@@ -14,3 +14,17 @@ export const landingPageSettings: Writable<OrgLandingPageJson> = writable(create
* no section is selected — the editor shows the section list.
*/
export const landingPageEditorSelection: Writable<LandingSectionKey | null> = writable(null);
type SettingsHeaderAction = {
label: string;
disabled: boolean;
loading: boolean;
onClick: null | (() => void | Promise<void>);
};
export const settingsHeaderAction: Writable<SettingsHeaderAction> = writable({
label: 'Save',
disabled: true,
loading: false,
onClick: null
});
@@ -81,15 +81,25 @@ export function generateBreadcrumbs(
const normalizedPathname = pathname.replace(/\/$/, '');
if (normalizedPathname !== parentBase) {
const activeSub = matchedNavItem.items.find((sub) => {
const subBase = sub.url.replace(/\/$/, '');
return normalizedPathname === subBase || normalizedPathname.startsWith(`${subBase}/`);
});
const activeSub = matchedNavItem.items.find((sub) => isActive(pathname, sub.url, sub.matchPattern, true));
if (activeSub) {
breadcrumbs.push({
label: t(activeSub.title),
href: activeSub.url
});
const activeSubBase = activeSub.url.replace(/\/$/, '');
if (normalizedPathname !== activeSubBase && activeSub.nestedRoutes && pathSegments.length > 1) {
const activeNestedRoute = activeSub.nestedRoutes.find((route) => route.path === pathSegments[1]);
if (activeNestedRoute) {
breadcrumbs.push({
label: t(activeNestedRoute.titleKey),
href: `${currentOrgPath}${matchedNavItem.path}/${activeNestedRoute.path}`
});
}
}
return breadcrumbs;
}
}
@@ -201,11 +201,27 @@ export const baseNavConfig: NavItemConfig[] = [
items: [
{
titleKey: 'settings.tabs.profile_tab',
path: '/settings'
path: '/settings',
matchPattern: '^/org/[^/]+/settings/?$'
},
{
titleKey: 'settings.tabs.organization_tab',
path: '/settings/org'
path: '/settings/org',
matchPattern: '^/org/[^/]+/settings/(org|customize-lms|domains|teams)(/.*)?$',
nestedRoutes: [
{
path: 'domains',
titleKey: 'settings.organization.organization_profile.custom_domain.heading'
},
{
path: 'teams',
titleKey: 'settings.organization.organization_profile.team.heading'
},
{
path: 'customize-lms',
titleKey: 'settings.tabs.customize_lms_tab'
}
]
},
{
titleKey: 'settings.tabs.landing_page_tab',
@@ -331,7 +347,9 @@ export function getOrgNavigationItems(
isActive: isActive(pagePathname, subUrl, subMatchPattern, true),
url: subUrl,
path: subConfig.path,
isPaid: subConfig.isPaid
matchPattern: subMatchPattern,
isPaid: subConfig.isPaid,
nestedRoutes: subConfig.nestedRoutes
};
});
}
@@ -410,7 +428,9 @@ export function getOrgNavigationGroups(
isActive: isActive(pathnameOnly, subUrl, subMatchPattern, true),
url: subUrl,
path: subConfig.path,
isPaid: subConfig.isPaid
matchPattern: subMatchPattern,
isPaid: subConfig.isPaid,
nestedRoutes: subConfig.nestedRoutes
};
});
}
@@ -16,6 +16,10 @@
snackbar.error('grade cant be more than max value');
grade = gradeMax;
}
if (grade && grade < 0) {
snackbar.error('grade cant be less than 0');
grade = 0;
}
});
</script>
@@ -24,6 +28,7 @@
placeholder={$t('course.navItem.lessons.exercises.new_exercise_modal.points')}
bind:value={grade}
max={gradeMax}
min={0}
type="number"
inputClassName="!w-16"
isDisabled={disableGrading}