fix(dashboard): show locked notice instead of teacher empty state on lessons (#760)

* fix(dashboard): show locked notice instead of teacher empty state on lessons

Students viewing a locked or progression-blocked lesson were falling
through to the teacher empty state with Get Started. Align lesson
access checks with the shared getStudentContentLockReason helper used
by exercises and the course layout, treat missing course content as
inaccessible for students, and use org student role for sidebar lock
blocking on the app host.

Co-authored-by: Rotimi Best <[email protected]>

* fix(dashboard): only show lesson lock notice when lock reason is known

Address Greptile review: avoid defaulting to teacher_locked when the
lesson is missing from the content tree, and wait for course/lesson
state before dismissing the student loading spinner.

Co-authored-by: Rotimi Best <[email protected]>

---------

]Co-authored-by: Rotimi Best <[email protected]>
This commit is contained in:
Rotimi Best
2026-07-16 09:56:35 +01:00
committed by GitHub
co-authored by Rotimi Best
parent c9b7c79365
commit 9681770270
4 changed files with 61 additions and 25 deletions
@@ -8,6 +8,7 @@
import * as Tooltip from '@cio/ui/base/tooltip';
import { PercentRingProgress } from '@cio/ui/custom/percent-ring-progress';
import { isOrgStudent } from '$lib/utils/store/app';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { t } from '$lib/utils/functions/translations';
import { courseApi, lessonApi } from '$features/course/api';
import { getOrderedNavigableContent, getContentRoute } from '$features/course/utils/content';
@@ -69,8 +70,10 @@
const showMarkComplete = $derived(!!lessonId && !exerciseId);
const currentLessonItem = $derived(lessonId ? lessonItems.find((l) => l.id === lessonId) : null);
const isTeacherLocked = $derived($isOrgStudent && currentLessonItem && !(currentLessonItem.isUnlocked ?? false));
const isProgressionLocked = $derived($isOrgStudent && currentLessonItem?.accessible === false && !isTeacherLocked);
const contentLockReason = $derived(
lessonId ? getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson) : null
);
const isLessonLocked = $derived($isOrgStudent === true && contentLockReason !== null);
const isVideoWatchLesson = $derived.by(() => {
if (!lessonId) return false;
@@ -98,7 +101,6 @@
const watchVideosRequired = $derived(lessonApi.lesson?.watchProgress?.videosRequired ?? 0);
const watchVideosComplete = $derived(lessonApi.lesson?.watchProgress?.videosComplete ?? 0);
const isLessonLocked = $derived(isTeacherLocked || isProgressionLocked);
const isVideoWatchComplete = $derived(isVideoWatchLesson && (lessonApi.lesson?.watchProgress?.isComplete ?? false));
const showVideoWatchCompleteState = $derived(isVideoWatchComplete || (isVideoWatchLesson && isLessonComplete));
const isMarkCompleteDisabled = $derived(
@@ -14,6 +14,7 @@
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';
import { isOrgStudent } from '$lib/utils/store/app';
interface Props {
path: string;
@@ -85,7 +86,8 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
{@const isLockedForStudent =
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -137,7 +139,8 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent = isStudent && (isContentLocked || contentItem.accessible === false)}
{@const isLockedForStudent =
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -17,6 +17,7 @@
import MODES from '$lib/utils/constants/mode';
import { profile } from '$lib/utils/store/user';
import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { currentOrg } from '$lib/utils/store/org';
import { snackbar } from '$features/ui/snackbar/store';
import { RefreshPageData, UnsavedChanges } from '$features/ui';
@@ -33,6 +34,7 @@
import * as Page from '@cio/ui/base/page';
import * as UnderlineTabs from '@cio/ui/custom/underline-tabs';
import { Empty } from '@cio/ui/custom/empty';
import { Spinner } from '@cio/ui/base/spinner';
import { RoleBasedSecurity } from '$features/ui';
import {
@@ -83,17 +85,31 @@
)
);
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
const isTeacherLocked = $derived.by(() => {
if ($isOrgStudent && currentLessonContentItem) {
return (currentLessonContentItem.isUnlocked ?? false) === false;
const contentLockReason = $derived(getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson));
const isLessonLockedForStudent = $derived($isOrgStudent === true && contentLockReason !== null);
const isStudentLessonStateReady = $derived.by(() => {
if ($isOrgStudent !== true) {
return true;
}
return (lessonApi.lesson?.isUnlocked ?? false) === false;
if (courseApi.course?.id !== courseId) {
return false;
}
if (contentLockReason !== null) {
return true;
}
if (currentLessonContentItem) {
return true;
}
if (lessonApi.lesson?.id === lessonId) {
return true;
}
return courseApi.course?.content != null;
});
const isProgressionLocked = $derived(
$isOrgStudent && currentLessonContentItem?.accessible === false && !isTeacherLocked
);
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));
@@ -450,7 +466,7 @@
<Page.Body>
{#snippet child()}
<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)}
{#if isLiveSessionLesson && mode === MODES.view && !isLessonLockedForStudent}
<div class="mb-4">
<LiveSessionCard
title={lessonTitle}
@@ -461,11 +477,12 @@
</div>
{/if}
{#if $isOrgStudent && !isLessonUnlocked}
<StudentContentLockedNotice
reason={isProgressionLocked ? 'progression_locked' : 'teacher_locked'}
contentType={ContentType.Lesson}
/>
{#if $isOrgStudent === true && !isStudentLessonStateReady}
<div class="flex justify-center py-16">
<Spinner />
</div>
{:else if isLessonLockedForStudent && contentLockReason}
<StudentContentLockedNotice reason={contentLockReason} contentType={ContentType.Lesson} />
{:else if mode === MODES.edit}
<UnderlineTabs.Root
bind:value={currentTabValue}
@@ -541,6 +558,13 @@
{/if}
</div>
{/key}
{:else if $isOrgStudent === true}
<Empty
title={$t('course.navItem.lessons.materials.no_content')}
icon={VideoIcon}
variant="page"
class="text-center"
/>
{:else}
<Empty
title={$t('course.navItem.lessons.materials.body_heading')}
@@ -554,11 +578,9 @@
variant="page"
class="text-center"
>
{#if !$isOrgStudent}
<Button onclick={toggleMode}>
{$t('course.navItem.lessons.materials.get_started')}
</Button>
{/if}
<Button onclick={toggleMode}>
{$t('course.navItem.lessons.materials.get_started')}
</Button>
</Empty>
{/if}
</div>
@@ -16,7 +16,16 @@
let { data }: Props = $props();
$effect(() => {
if (!data.lesson) return;
if (!data.lessonId) return;
if (!data.lesson) {
if (lessonApi.lesson?.id === data.lessonId) {
lessonApi.lesson = null;
}
return;
}
const lesson = data.lesson;
if (lessonApi.lesson?.id === lesson.id) return;