fix(course): restore mobile mark complete and outline progress card (#942)

* fix(course): pass progress into mobile outline sheet card

The outline sheet bound CourseProgressCard with {courseProgress}, which
sets a courseProgress prop the card does not read. progress was
undefined, so opening the sheet crashed on progress.percent. Pass
progress={courseProgress} and treat missing progress as zeros.

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

* fix(course): keep mark complete on mobile student lessons

The mobile bottom nav hid the whole lesson action row, so students had no way to mark a lesson complete. Hide only prev/next when the bottom bar is shown.

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

* fix(course): show a check on the right of a completed lesson title

When a student marks a lesson complete, hide the disabled Mark as Complete
button and place a filled check icon on the right of the lesson title.

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

* fix(course): show completion check on mobile bottom-nav title

Move the filled check from the lesson page heading to the right of
the active lesson title in the mobile bottom bar.

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

---------
This commit is contained in:
Rotimi Best
2026-08-20 18:03:03 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 4fae088626
commit 44f09bbe6b
7 changed files with 95 additions and 48 deletions
@@ -4,27 +4,33 @@
import type { ContentProgress } from '$features/course/utils/content'; import type { ContentProgress } from '$features/course/utils/content';
interface Props { interface Props {
progress: ContentProgress; progress?: ContentProgress;
class?: string; class?: string;
} }
let { progress, class: className = '' }: Props = $props(); let { progress, class: className = '' }: Props = $props();
const percent = $derived(progress?.percent ?? 0);
const lessonsComplete = $derived(progress?.lessonsComplete ?? 0);
const lessonsTotal = $derived(progress?.lessonsTotal ?? 0);
const exercisesComplete = $derived(progress?.exercisesComplete ?? 0);
const exercisesTotal = $derived(progress?.exercisesTotal ?? 0);
</script> </script>
<div class={className}> <div class={className}>
<div class="flex items-baseline justify-between gap-2"> <div class="flex items-baseline justify-between gap-2">
<span class="text-xs font-medium">{$t('course.sidebar.progress.title')}</span> <span class="text-xs font-medium">{$t('course.sidebar.progress.title')}</span>
<span class="ui:text-primary text-xs font-semibold tabular-nums">{progress.percent}%</span> <span class="ui:text-primary text-xs font-semibold tabular-nums">{percent}%</span>
</div> </div>
<Progress value={progress.percent} max={100} class="ui:h-1.5 mt-2" /> <Progress value={percent} max={100} class="ui:h-1.5 mt-2" />
<p class="ui:text-muted-foreground mt-2 truncate text-[11px]"> <p class="ui:text-muted-foreground mt-2 truncate text-[11px]">
{$t('course.sidebar.progress.lessons', { {$t('course.sidebar.progress.lessons', {
completed: progress.lessonsComplete, completed: lessonsComplete,
total: progress.lessonsTotal total: lessonsTotal
})}{#if progress.exercisesTotal > 0} })}{#if exercisesTotal > 0}
· {$t('course.sidebar.progress.exercises', { · {$t('course.sidebar.progress.exercises', {
completed: progress.exercisesComplete, completed: exercisesComplete,
total: progress.exercisesTotal total: exercisesTotal
})}{/if} })}{/if}
</p> </p>
</div> </div>
@@ -29,9 +29,11 @@
courseId: string; courseId: string;
/** When on an exercise page, pass this to show prev/next content in content order (no mark-complete). */ /** When on an exercise page, pass this to show prev/next content in content order (no mark-complete). */
exerciseId?: string; exerciseId?: string;
/** When false, hide prev/next chevrons (mobile bottom nav already has them). */
showPrevNext?: boolean;
} }
let { lessonId, courseId, exerciseId }: Props = $props(); let { lessonId, courseId, exerciseId, showPrevNext = true }: Props = $props();
let isMarkingComplete = $state(false); let isMarkingComplete = $state(false);
@@ -229,7 +231,7 @@
{watchProgressTooltip} {watchProgressTooltip}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
{:else if showMarkComplete && lessonId && !isLessonLocked && (showVideoWatchCompleteState || !isVideoWatchLesson)} {:else if showMarkComplete && lessonId && !isLessonLocked && !isLessonComplete && !isVideoWatchLesson}
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
@@ -242,40 +244,42 @@
</Button> </Button>
{/if} {/if}
<div class="flex items-center gap-1"> {#if showPrevNext}
<Tooltip.Root> <div class="flex items-center gap-1">
<Tooltip.Trigger> <Tooltip.Root>
<Button <Tooltip.Trigger>
size="icon-sm" <Button
variant="outline" size="icon-sm"
onclick={() => goToContent(prevNextContent.prev)} variant="outline"
disabled={isPrevDisabled || isPrevNavBlocked} onclick={() => goToContent(prevNextContent.prev)}
aria-label={$t('course.navItem.lessons.prev')} disabled={isPrevDisabled || isPrevNavBlocked}
> aria-label={$t('course.navItem.lessons.prev')}
<ChevronLeftIcon size={14} /> >
</Button> <ChevronLeftIcon size={14} />
</Tooltip.Trigger> </Button>
<Tooltip.Content side="bottom" sideOffset={4}> </Tooltip.Trigger>
{prevNavTooltip} <Tooltip.Content side="bottom" sideOffset={4}>
</Tooltip.Content> {prevNavTooltip}
</Tooltip.Root> </Tooltip.Content>
</Tooltip.Root>
<Tooltip.Root> <Tooltip.Root>
<Tooltip.Trigger> <Tooltip.Trigger>
<Button <Button
size="icon-sm" size="icon-sm"
variant="outline" variant="outline"
onclick={() => goToContent(prevNextContent.next)} onclick={() => goToContent(prevNextContent.next)}
disabled={isNextDisabled || isNextNavBlocked} disabled={isNextDisabled || isNextNavBlocked}
aria-label={$t('course.navItem.lessons.next')} aria-label={$t('course.navItem.lessons.next')}
> >
<ChevronRightIcon size={14} /> <ChevronRightIcon size={14} />
</Button> </Button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Content side="bottom" sideOffset={4}> <Tooltip.Content side="bottom" sideOffset={4}>
{nextNavTooltip} {nextNavTooltip}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>
</div> </div>
{/if}
</Tooltip.Provider> </Tooltip.Provider>
</div> </div>
@@ -77,6 +77,7 @@
<PublicCourseBottomNav <PublicCourseBottomNav
{positionLabel} {positionLabel}
sublineLabel={activeItem?.title} sublineLabel={activeItem?.title}
sublineComplete={activeItem?.isComplete ?? false}
hasPrev={!isPreviousDisabled} hasPrev={!isPreviousDisabled}
hasNext={!isNextDisabled} hasNext={!isNextDisabled}
onPrev={() => goToContent(previousItem)} onPrev={() => goToContent(previousItem)}
@@ -52,7 +52,7 @@
</Drawer.Header> </Drawer.Header>
<div class="shrink-0 px-3 pb-2"> <div class="shrink-0 px-3 pb-2">
<CourseProgressCard {courseProgress} class="rounded-lg border p-3" /> <CourseProgressCard progress={courseProgress} class="rounded-lg border p-3" />
</div> </div>
<div bind:this={scrollContainer} class="flex-1 overflow-y-auto px-2 pb-[max(env(safe-area-inset-bottom),1rem)]"> <div bind:this={scrollContainer} class="flex-1 overflow-y-auto px-2 pb-[max(env(safe-area-inset-bottom),1rem)]">
@@ -433,8 +433,8 @@
</Page.HeaderContent> </Page.HeaderContent>
<Page.Action> <Page.Action>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
{#if mode === MODES.view && $isCourseLearnerView && !showMobileBottomNav} {#if mode === MODES.view && $isCourseLearnerView}
<ContentNavigationActions {lessonId} {courseId} /> <ContentNavigationActions {lessonId} {courseId} showPrevNext={!showMobileBottomNav} />
{/if} {/if}
<RoleBasedSecurity allowedRoles={[1, 2]}> <RoleBasedSecurity allowedRoles={[1, 2]}>
@@ -124,6 +124,25 @@
{/snippet} {/snippet}
</Story> </Story>
<Story name="Bottom nav · completed lesson">
{#snippet template()}
<div class="ui:relative ui:min-h-[200px] ui:bg-background">
<PublicCourse.PublicCourseBottomNav
positionLabel="2 / 6"
sublineLabel="Delving into Data Analysis with Pandas"
sublineComplete
hasPrev
hasNext
centerVariant="ring"
progressPercent={33}
onPrev={() => console.log('prev')}
onNext={() => console.log('next')}
onOpenSheet={() => console.log('open sheet')}
/>
</div>
{/snippet}
</Story>
<Story name="Mobile sheet · collapse + long course"> <Story name="Mobile sheet · collapse + long course">
{#snippet template()} {#snippet template()}
{@const activeSlug = 'module-10-lesson-4'} {@const activeSlug = 'module-10-lesson-4'}
@@ -8,6 +8,8 @@
/** Current position — e.g. "3 of 12" — plus a subline — e.g. "Hallucination & limitations". */ /** Current position — e.g. "3 of 12" — plus a subline — e.g. "Hallucination & limitations". */
positionLabel: string; positionLabel: string;
sublineLabel?: string; sublineLabel?: string;
/** When true, a filled check is shown to the right of `sublineLabel`. */
sublineComplete?: boolean;
hasPrev: boolean; hasPrev: boolean;
hasNext: boolean; hasNext: boolean;
onPrev: () => void; onPrev: () => void;
@@ -27,6 +29,7 @@
let { let {
positionLabel, positionLabel,
sublineLabel, sublineLabel,
sublineComplete = false,
hasPrev, hasPrev,
hasNext, hasNext,
onPrev, onPrev,
@@ -98,7 +101,21 @@
{positionLabel} {positionLabel}
</span> </span>
{#if sublineLabel} {#if sublineLabel}
<span class="ui:max-w-[55vw] ui:truncate ui:text-sm ui:font-medium ui:text-foreground">{sublineLabel}</span> <span class="ui:flex ui:max-w-[55vw] ui:min-w-0 ui:items-center ui:gap-1">
<span class="ui:min-w-0 ui:truncate ui:text-sm ui:font-medium ui:text-foreground">{sublineLabel}</span>
{#if sublineComplete}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
class="ui:size-3.5 ui:shrink-0 ui:text-primary"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" fill="currentColor"></circle>
<path d="m9 12 2 2 4-4" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
{/if}
</span>
{/if} {/if}
</span> </span>
</button> </button>