From b349d033903e025501ab1dfea4fe7bc934484586 Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Wed, 29 Jul 2026 22:49:25 -0400 Subject: [PATCH] Show the course curriculum to users who haven't purchased The course dashboard rendered only a marketing page for non-purchasers, so free lessons existed but could not be discovered. Add a curriculum outline listing every published module and lesson, with free lessons linked and the rest locked behind a tooltip. Replace the bare 403 on paid lessons with a redirect to the course page plus an explanation, and make locked lessons in the lesson sidebar clickable so they route through it. Co-Authored-By: Claude Opus 5 (1M context) --- app/Livewire/Customer/Course/Index.php | 39 ++ app/Livewire/Customer/Course/LessonShow.php | 6 +- .../views/livewire/customer/course.blade.php | 95 ++++- .../customer/course/lesson-show.blade.php | 15 +- tests/Feature/CourseContentTest.php | 393 +++++++++++++++++- 5 files changed, 534 insertions(+), 14 deletions(-) diff --git a/app/Livewire/Customer/Course/Index.php b/app/Livewire/Customer/Course/Index.php index dafbb52d..308cabcd 100644 --- a/app/Livewire/Customer/Course/Index.php +++ b/app/Livewire/Customer/Course/Index.php @@ -3,9 +3,12 @@ namespace App\Livewire\Customer\Course; use App\Models\Course; +use App\Models\CourseLesson; +use App\Models\CourseModule; use App\Models\LessonProgress; use App\Models\Product; use App\Models\ProductPrice; +use Illuminate\Support\Collection; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; use Livewire\Attributes\Title; @@ -117,4 +120,40 @@ public function completedCount(): int { return count($this->completedLessonIds); } + + /** + * Published modules with at least one published lesson. + * + * Drives the pre-purchase curriculum outline, which lists every lesson — + * locked or not — so non-purchasers can see the whole course. + * + * Keys are preserved so the view can number each module by its real position + * in the course rather than its position within this filtered subset. + * + * @return Collection + */ + #[Computed] + public function outlineModules(): Collection + { + if (! $this->course) { + return collect(); + } + + return $this->course->modules + ->filter(fn (CourseModule $module): bool => $module->is_published + && $module->lessons->contains(fn (CourseLesson $lesson): bool => $lesson->is_published)); + } + + #[Computed] + public function hasFreeLessons(): bool + { + return $this->outlineModules + ->flatMap(fn (CourseModule $module) => $module->lessons) + ->contains(fn (CourseLesson $lesson): bool => $this->isFreePreviewLesson($lesson)); + } + + public function isFreePreviewLesson(CourseLesson $lesson): bool + { + return $lesson->is_free && $lesson->is_published; + } } diff --git a/app/Livewire/Customer/Course/LessonShow.php b/app/Livewire/Customer/Course/LessonShow.php index 6ce0e012..956e5929 100644 --- a/app/Livewire/Customer/Course/LessonShow.php +++ b/app/Livewire/Customer/Course/LessonShow.php @@ -31,7 +31,11 @@ public function mount(CourseLesson $lesson): void abort_unless($this->isAdmin || ($this->lesson->is_published && $this->lesson->module->is_published), 404); if (! $this->lesson->is_free && ! $this->hasPurchased && ! $this->isAdmin) { - abort(403, 'You need Pro access to view this lesson.'); + session()->flash('message', 'That lesson is part of the full course. Purchase the Masterclass to unlock it.'); + + $this->redirect(route('customer.course.index'), navigate: true); + + return; } $this->skipIntroOutro = session()->has(self::VIDEO_PLAYED_SESSION_KEY); diff --git a/resources/views/livewire/customer/course.blade.php b/resources/views/livewire/customer/course.blade.php index 6ad2a3fb..c53b9500 100644 --- a/resources/views/livewire/customer/course.blade.php +++ b/resources/views/livewire/customer/course.blade.php @@ -99,7 +99,7 @@ class="h-2 rounded-full bg-emerald-500 transition-all" @endif @else {{-- Not purchased: Full-width marketing/purchase page --}} -
+
{{-- Hero --}}
{{-- Background glow --}} @@ -109,9 +109,9 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
@if ($this->priceIncreased) - New Course + Masterclass @else - New Course — Early Bird + Masterclass — Early Bird @endif @@ -179,7 +179,7 @@ class="mx-auto mt-8 w-full max-w-sm rounded-xl bg-white/70 p-4 ring-1 ring-zinc-
{{-- What's Included --}} -
+
@@ -212,6 +212,93 @@ class="mx-auto mt-8 w-full max-w-sm rounded-xl bg-white/70 p-4 ring-1 ring-zinc-

One-time payment. All current and future content.

+ + {{-- Locked lesson notice --}} + @if (session('message')) +
+ + {{ session('message') }} + +
+ @endif + + {{-- Course curriculum --}} + @if ($this->outlineModules->isNotEmpty()) +
+
+ Course curriculum + @if ($this->hasFreeLessons) + Free lessons included + @endif +
+ + @if ($this->hasFreeLessons) + Start with the lessons you can watch right now — everything else unlocks when you get the course. + @else + Every lesson unlocks when you get the course. + @endif + + +
+ @foreach ($this->outlineModules as $moduleIndex => $module) + @php + $publishedLessons = $module->lessons->where('is_published', true); + $moduleHasFreeLesson = $publishedLessons->contains(fn ($moduleLesson) => $this->isFreePreviewLesson($moduleLesson)); + @endphp +
+
+
+ {{ str_pad($moduleIndex + 1, 2, '0', STR_PAD_LEFT) }} +
+
+ {{ $module->title }} + @if ($module->description) + {{ $module->description }} + @endif +
+
+ +
+ @foreach ($publishedLessons as $lesson) + @php + $isPreviewable = $this->isFreePreviewLesson($lesson); + @endphp +
+ {{-- Fixed-width slot keeps titles aligned whether or not a lock is shown --}} +
+ @unless ($isPreviewable) + + + + @endunless +
+
+ @if ($isPreviewable) + + {{ $lesson->title }} + + @else + {{ $lesson->title }} + @endif +
+ @if ($lesson->duration_in_seconds) + {{ gmdate('i:s', $lesson->duration_in_seconds) }} + @endif +
+ @endforeach +
+
+ @endforeach +
+
+ @endif +
@endif
diff --git a/resources/views/livewire/customer/course/lesson-show.blade.php b/resources/views/livewire/customer/course/lesson-show.blade.php index f54d04f1..3fc949a9 100644 --- a/resources/views/livewire/customer/course/lesson-show.blade.php +++ b/resources/views/livewire/customer/course/lesson-show.blade.php @@ -98,17 +98,21 @@ class="shrink-0 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibo $isDraft = ! $outlineLesson->is_published; $isLessonComplete = in_array($outlineLesson->id, $this->completedLessonIds); $canAccess = $outlineLesson->is_free || $this->hasPurchased || $this->isAdmin; - $isClickable = $this->isAdmin || (! $isDraft && $canAccess); - $isLocked = ! $isClickable && ! $isDraft; + $isLocked = ! $canAccess && ! $isDraft; + $isClickable = $this->isAdmin || ! $isDraft; @endphp @if ($isClickable) + {{-- Locked lessons stay clickable: they redirect to the course page with an explanation --}} + @if ($isLocked) + + @endif {{ $outlineLesson->title }} @if ($isDraft) Coming Soon @@ -119,9 +123,6 @@ class="flex items-center gap-3 px-4 py-2 text-sm transition-colors {{ $isCurrent @else
- @if ($isLocked) - - @endif {{ $outlineLesson->title }} @if ($isDraft) Coming Soon diff --git a/tests/Feature/CourseContentTest.php b/tests/Feature/CourseContentTest.php index 4c3b7365..40c326ca 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -82,6 +82,8 @@ public function course_dashboard_shows_purchase_page_for_non_owners(): void ->test(Index::class) ->assertSee('Build native apps') ->assertSee('Get Early Bird Access') + ->assertSee('Masterclass — Early Bird', escape: false) + ->assertDontSee('New Course') ->assertSee('$199'); Carbon::setTestNow(); @@ -101,6 +103,9 @@ public function course_dashboard_shows_299_pricing_after_deadline(): void ->test(Index::class) ->assertSee('$299') ->assertSee('Get Access') + ->assertSee('Masterclass') + ->assertDontSee('New Course') + ->assertDontSee('Early Bird') ->assertDontSee('Get Early Bird Access'); Carbon::setTestNow(); @@ -175,6 +180,233 @@ public function course_dashboard_shows_modules_and_lessons_for_owners(): void ->assertSee('Test Lesson'); } + #[Test] + public function course_dashboard_lists_the_curriculum_for_non_owners(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Getting Started', + ]); + $lesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Installing NativePHP', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Course curriculum') + ->assertSee('Free lessons included') + ->assertSee('Getting Started') + ->assertSee('Installing NativePHP') + ->assertSee(route('customer.course.lesson', $lesson), escape: false); + } + + #[Test] + public function course_dashboard_lists_paid_only_modules_for_non_owners(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'title' => 'Paid Only Module', + ]); + $lesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + 'title' => 'Paid Only Lesson', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Course curriculum') + ->assertSee('Paid Only Module') + ->assertSee('Paid Only Lesson') + ->assertSee('Every lesson unlocks when you get the course.') + ->assertDontSee('Free lessons included') + ->assertDontSee(route('customer.course.lesson', $lesson), escape: false); + } + + #[Test] + public function locked_lessons_show_a_lock_with_an_unlock_prompt(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + 'title' => 'Locked Lesson', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Locked Lesson') + ->assertSee('Buy the course to unlock all lessons') + ->assertSeeHtml('data-flux-tooltip'); + } + + #[Test] + public function free_lessons_are_not_labelled_with_a_free_pill(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create(['course_id' => $course->id]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Watchable Lesson', + ]); + + $html = Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Watchable Lesson') + ->html(); + + $this->assertDoesNotMatchRegularExpression('/<[^>]*badge[^>]*>\s*Free\s*create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Partly Released Module', + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Released Lesson', + ]); + CourseLesson::factory()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Unreleased Lesson', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Partly Released Module') + ->assertSee('Released Lesson') + ->assertDontSee('Unreleased Lesson'); + } + + #[Test] + public function course_dashboard_excludes_modules_with_no_published_lessons(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Empty Module', + ]); + CourseLesson::factory()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Unreleased Lesson', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertDontSee('Course curriculum') + ->assertDontSee('Empty Module') + ->assertDontSee('Unreleased Lesson'); + } + + #[Test] + public function course_dashboard_excludes_unpublished_modules_from_the_curriculum(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Draft Module', + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Lesson In Draft Module', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertDontSee('Course curriculum') + ->assertDontSee('Draft Module') + ->assertDontSee('Lesson In Draft Module'); + } + + #[Test] + public function curriculum_modules_keep_their_real_position_in_the_course(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + + foreach ([1, 2] as $sortOrder) { + $paidModule = CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'sort_order' => $sortOrder, + ]); + CourseLesson::factory()->published()->create([ + 'course_module_id' => $paidModule->id, + 'is_free' => false, + ]); + } + + $freeModule = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Third Module Is Free', + 'sort_order' => 3, + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $freeModule->id, + ]); + + $outlineModules = Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Third Module Is Free') + ->instance() + ->outlineModules(); + + $this->assertSame([0, 1, 2], array_keys($outlineModules->all())); + $this->assertSame($freeModule->id, $outlineModules->last()->id); + } + + #[Test] + public function course_dashboard_locks_paid_lessons_inside_a_free_module_for_non_owners(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Mixed Module', + ]); + $freeLesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Free Intro Lesson', + 'sort_order' => 1, + ]); + $paidLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + 'title' => 'Paid Deep Dive Lesson', + 'sort_order' => 2, + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Free Intro Lesson') + ->assertSee('Paid Deep Dive Lesson') + ->assertSee(route('customer.course.lesson', $freeLesson), escape: false) + ->assertDontSee(route('customer.course.lesson', $paidLesson), escape: false); + } + #[Test] public function free_lesson_is_accessible_without_purchase(): void { @@ -192,7 +424,7 @@ public function free_lesson_is_accessible_without_purchase(): void } #[Test] - public function pro_lesson_is_blocked_without_purchase(): void + public function paid_lesson_redirects_to_the_course_page_without_purchase(): void { $user = User::factory()->create(); $course = Course::factory()->published()->create(); @@ -204,7 +436,164 @@ public function pro_lesson_is_blocked_without_purchase(): void Livewire::actingAs($user) ->test(LessonShow::class, ['lesson' => $lesson]) - ->assertForbidden(); + ->assertRedirect(route('customer.course.index')); + + $this->assertSame( + 'That lesson is part of the full course. Purchase the Masterclass to unlock it.', + session('message'), + ); + } + + #[Test] + public function paid_lesson_redirect_explains_itself_on_the_course_page(): void + { + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + ]); + + $this->withoutVite() + ->actingAs($user) + ->get(route('customer.course.lesson', $lesson)) + ->assertRedirect(route('customer.course.index')); + + $this->withoutVite() + ->actingAs($user) + ->get(route('customer.course.index')) + ->assertOk() + ->assertSee('That lesson is part of the full course. Purchase the Masterclass to unlock it.', escape: false) + ->assertSee('Build native apps'); + } + + #[Test] + public function locked_lessons_in_the_lesson_sidebar_are_clickable(): void + { + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create(['course_id' => $course->id]); + $freeLesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Watchable Lesson', + 'sort_order' => 1, + ]); + $lockedLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + 'title' => 'Locked Sidebar Lesson', + 'sort_order' => 2, + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $freeLesson]) + ->assertSee('Locked Sidebar Lesson') + ->assertSee(route('customer.course.lesson', $lockedLesson), escape: false); + } + + #[Test] + public function draft_lessons_in_the_lesson_sidebar_stay_unclickable(): void + { + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create(['course_id' => $course->id]); + $freeLesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Watchable Lesson', + 'sort_order' => 1, + ]); + $draftLesson = CourseLesson::factory()->free()->create([ + 'course_module_id' => $module->id, + 'title' => 'Draft Sidebar Lesson', + 'sort_order' => 2, + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $freeLesson]) + ->assertSee('Draft Sidebar Lesson') + ->assertSee('Coming Soon') + ->assertDontSee(route('customer.course.lesson', $draftLesson), escape: false); + } + + #[Test] + public function purchase_page_orders_hero_features_notice_then_curriculum(): void + { + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Getting Started', + ]); + CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + ]); + $lockedLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + ]); + + $this->withoutVite() + ->actingAs($user) + ->get(route('customer.course.lesson', $lockedLesson)) + ->assertRedirect(route('customer.course.index')); + + $html = $this->withoutVite() + ->actingAs($user) + ->get(route('customer.course.index')) + ->assertOk() + ->getContent(); + + $hero = strpos($html, 'Build native apps'); + $features = strpos($html, 'Zero to Published'); + $notice = strpos($html, 'Purchase the Masterclass to unlock it.'); + $curriculum = strpos($html, 'Course curriculum'); + + $this->assertNotFalse($hero); + $this->assertNotFalse($features); + $this->assertNotFalse($notice); + $this->assertNotFalse($curriculum); + + $this->assertLessThan($features, $hero, 'Hero should come before the feature cards'); + $this->assertLessThan($notice, $features, 'Feature cards should come before the locked-lesson notice'); + $this->assertLessThan($curriculum, $notice, 'Locked-lesson notice should come before the curriculum'); + } + + #[Test] + public function paid_lesson_no_longer_returns_a_dead_end_403(): void + { + config(['app.debug' => false]); + + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + ]); + + $this->withoutVite() + ->actingAs($user) + ->get(route('customer.course.lesson', $lesson)) + ->assertStatus(302) + ->assertRedirect(route('customer.course.index')) + ->assertDontSee('You need Pro access to view this lesson.'); + } + + #[Test] + public function unpublished_lesson_still_returns_404_without_purchase(): void + { + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->create([ + 'course_module_id' => $module->id, + 'is_free' => false, + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertNotFound(); } #[Test]