feat: add sequential progression and video watch lesson completion

Gate student content by completion order, track per-video watch progress
with seek enforcement, and auto-complete lessons at the configured
threshold. Show ring progress in the lesson header, keep watch progress
on rewatch, and sync progression locks client-side so the next item
unlocks after completion.
This commit is contained in:
Rotimi Best
2026-06-11 14:48:34 +01:00
parent c8154409f5
commit 52d9bf18c3
94 changed files with 3499 additions and 564 deletions
@@ -7,6 +7,7 @@ export const load = async ({ params, cookies }) => {
const exerciseId = params.exerciseId || '';
if (!courseId || !exerciseId) {
console.log('courseId', courseId, 'exerciseId', exerciseId);
return { courseId, exerciseId, exercise: null, submissions: [], mySubmissions: [] };
}
@@ -31,6 +32,7 @@ export const load = async ({ params, cookies }) => {
const mySubmissions = mySubmissionData;
const submissions = Array.isArray(overview.allSubmissions) ? overview.allSubmissions : [];
console.log('submissions', submissions.length, 'mySubmissions', mySubmissions.length);
return {
courseId,
@@ -2,19 +2,38 @@
import { get } from 'svelte/store';
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { Empty } from '@cio/ui/custom/empty';
import LockIcon from '@lucide/svelte/icons/lock';
import { Spinner } from '@cio/ui/base/spinner';
import * as Page from '@cio/ui/base/page';
import { ExercisePage } from '$features/course/pages';
import { questionnaireMetaData, reset } from '$features/course/components/exercise/store';
import { courseApi } from '$features/course/api';
import { isOrgStudent } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { ContentType } from '@cio/utils/constants/content';
let { data = $bindable() } = $props();
const path = `/courses/${data.courseId}/lessons`;
const isLockedForStudent = $derived($isOrgStudent && data.exercise?.isUnlocked === false);
const isCourseReady = $derived(courseApi.course?.id === data.courseId);
const contentLockReason = $derived(
getStudentContentLockReason(courseApi.course, data.exerciseId, ContentType.Exercise)
);
const isLockedForStudent = $derived.by(() => {
if (!$isOrgStudent) return false;
if (data.exercise?.isUnlocked === false) {
return true;
}
if (!data.exercise || contentLockReason !== null) {
return true;
}
return false;
});
$effect(() => {
if (!data.exercise || isLockedForStudent) return;
@@ -32,14 +51,10 @@
</script>
<Page.Root class="mx-auto flex w-full px-3 sm:w-[90%] sm:px-4 lg:max-w-5xl">
{#if isLockedForStudent}
<Empty
title={$t('course.navItem.lessons.content_locked_title')}
description={$t('course.navItem.lessons.content_locked_description')}
icon={LockIcon}
variant="page"
class="text-center"
/>
{#if $isOrgStudent && !isCourseReady}
<div class="flex justify-center py-16">
<Spinner />
</div>
{:else}
<ExercisePage
exerciseId={data.exerciseId}