fix(dashboard): use learner view for lesson lock empty states (#764)

Lock checks gated on isOrgStudent missed org-site students where
isStudentExperience is true but org role may be unset. Introduce
isCourseLearnerView (student experience OR org student) and mirror
the exercise page branch structure so locked lessons always show
StudentContentLockedNotice instead of the teacher empty state.

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-07-16 11:29:59 +01:00
committed by GitHub
co-authored by Cursor Agent Rotimi Best
parent 7a867dad45
commit 3ef286ed39
5 changed files with 67 additions and 26 deletions
@@ -7,7 +7,7 @@
import { Button } from '@cio/ui/base/button';
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 { isCourseLearnerView } 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';
@@ -52,6 +52,16 @@
function goToContent(target: CourseContentItem | null) {
if (!target) return;
if ($isCourseLearnerView) {
const targetType = target.type === ContentType.Lesson ? ContentType.Lesson : ContentType.Exercise;
const lockReason = getStudentContentLockReason(courseApi.course, target.id, targetType);
if (lockReason) {
return;
}
}
const courseIdResolved = courseApi.course?.id;
if (!courseIdResolved) return;
const path = getContentRoute(courseIdResolved, target);
@@ -73,7 +83,7 @@
const contentLockReason = $derived(
lessonId ? getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson) : null
);
const isLessonLocked = $derived($isOrgStudent === true && contentLockReason !== null);
const isLessonLocked = $derived($isCourseLearnerView && contentLockReason !== null);
const isVideoWatchLesson = $derived.by(() => {
if (!lessonId) return false;
@@ -107,7 +117,7 @@
isMarkingComplete || isLessonLocked || isLessonComplete || isVideoWatchLesson
);
const showWatchProgress = $derived(
!!lessonId && $isOrgStudent && isVideoWatchLesson && !showVideoWatchCompleteState && !isLessonLocked
!!lessonId && $isCourseLearnerView && isVideoWatchLesson && !showVideoWatchCompleteState && !isLessonLocked
);
const watchProgressTooltip = $derived(
@@ -14,7 +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';
import { isCourseLearnerView } from '$lib/utils/store/app';
interface Props {
path: string;
@@ -99,7 +99,7 @@
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent =
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
$isCourseLearnerView && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -151,8 +151,7 @@
<Sidebar.MenuSubButton isActive={(path || page.url.pathname).includes(contentItem.id)}>
{#snippet child({ props })}
{@const isContentLocked = (contentItem.isUnlocked ?? true) === false}
{@const isLockedForStudent =
$isOrgStudent === true && (isContentLocked || contentItem.accessible === false)}
{@const isLockedForStudent = $isCourseLearnerView && (isContentLocked || contentItem.accessible === false)}
<a
href={resolve(getContentRoute(id, contentItem), {})}
aria-disabled={isLockedForStudent}
@@ -16,7 +16,7 @@
import MODES from '$lib/utils/constants/mode';
import { profile } from '$lib/utils/store/user';
import { isOrgStudent, isStudentExperience } from '$lib/utils/store/app';
import { isCourseLearnerView, 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';
@@ -86,9 +86,8 @@
);
const lessonTitle = $derived(currentLessonContentItem?.title || lessonApi.lesson?.title || 'Lesson');
const contentLockReason = $derived(getStudentContentLockReason(courseApi.course, lessonId, ContentType.Lesson));
const isLessonLockedForStudent = $derived($isOrgStudent === true && contentLockReason !== null);
const isStudentLessonStateReady = $derived.by(() => {
if ($isOrgStudent !== true) {
if (!$isCourseLearnerView) {
return true;
}
@@ -466,7 +465,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 && !isLessonLockedForStudent}
{#if isLiveSessionLesson && mode === MODES.view && !($isCourseLearnerView && contentLockReason)}
<div class="mb-4">
<LiveSessionCard
title={lessonTitle}
@@ -477,12 +476,39 @@
</div>
{/if}
{#if $isOrgStudent === true && !isStudentLessonStateReady}
<div class="flex justify-center py-16">
<Spinner />
</div>
{:else if isLessonLockedForStudent && contentLockReason}
<StudentContentLockedNotice reason={contentLockReason} contentType={ContentType.Lesson} />
{#if $isCourseLearnerView}
{#if !isStudentLessonStateReady}
<div class="flex justify-center py-16">
<Spinner />
</div>
{:else if contentLockReason}
<StudentContentLockedNotice reason={contentLockReason} contentType={ContentType.Lesson} />
{:else if lessonApi.lesson && !isMaterialsEmpty}
{#key lessonId}
<div class="mb-20 flex w-full flex-col" in:fade={{ delay: 500 }} out:fade>
{#if !hasLessonVideos}
<LessonMaterialActions showSummarize {lessonId} alignWithNote />
{/if}
{#each viewModeComponents as Component, index (index)}
<Component {mode} {lessonId} {courseId} />
{/each}
{#if $currentOrg.customization?.apps?.comments}
<hr class="my-2" />
<Comments {lessonId} />
{/if}
</div>
{/key}
{:else}
<Empty
title={$t('course.navItem.lessons.materials.no_content')}
icon={VideoIcon}
variant="page"
class="text-center"
/>
{/if}
{:else if mode === MODES.edit}
<UnderlineTabs.Root
bind:value={currentTabValue}
@@ -558,13 +584,6 @@
{/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')}
+13
View File
@@ -32,6 +32,19 @@ export const isStudentExperience = derived([globalStore, isOrgStudent], ([$gs, $
return $isStudent ?? false;
});
/**
* True when course lesson/exercise pages should render the learner UI
* (locked notices, student empty states, no teacher authoring CTAs).
* Cloud org-site visitors always get the learner view; on the app host
* only org-role students do.
*/
export const isCourseLearnerView = derived(
[isStudentExperience, isOrgStudent],
([$isStudentExperience, $isOrgStudent]) => {
return $isStudentExperience || $isOrgStudent === true;
}
);
/**
* The root path for navigation: '/lms' for students, '/org/{siteName}' for admin/teacher
*/
@@ -25,7 +25,7 @@
import { page } from '$app/state';
import { profile } from '$lib/utils/store/user';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isOrgStudent } from '$lib/utils/store/app';
import { isCourseLearnerView } from '$lib/utils/store/app';
import { t } from '$lib/utils/functions/translations';
import { ContentType } from '@cio/utils/constants/content';
import type { CourseMember } from '$features/course/utils/types';
@@ -104,7 +104,7 @@
const showContentAskAiBar = $derived(
isCourseReady &&
isLessonOrExercisePage &&
!($isOrgStudent && isContentLockedForStudent) &&
!($isCourseLearnerView && isContentLockedForStudent) &&
sidePanel.activePanelId !== AI_ASSISTANT_PANEL_ID
);