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