diff --git a/app/Filament/Resources/CourseLessonResource.php b/app/Filament/Resources/CourseLessonResource.php index 00347fd9..23b49d9e 100644 --- a/app/Filament/Resources/CourseLessonResource.php +++ b/app/Filament/Resources/CourseLessonResource.php @@ -49,6 +49,12 @@ public static function form(Schema $schema): Schema Forms\Components\Textarea::make('description') ->rows(3), + Forms\Components\MarkdownEditor::make('notes') + ->label('Lesson notes') + ->helperText('Markdown — commands, code samples, links, etc. Shown beneath the video.') + ->inlineLabel(false) + ->columnSpanFull(), + Forms\Components\TextInput::make('vimeo_id') ->label('Vimeo ID') ->maxLength(255), diff --git a/app/Filament/Resources/CourseModuleResource/RelationManagers/LessonsRelationManager.php b/app/Filament/Resources/CourseModuleResource/RelationManagers/LessonsRelationManager.php index 743d17d6..128de497 100644 --- a/app/Filament/Resources/CourseModuleResource/RelationManagers/LessonsRelationManager.php +++ b/app/Filament/Resources/CourseModuleResource/RelationManagers/LessonsRelationManager.php @@ -34,6 +34,11 @@ public function form(Schema $schema): Schema Forms\Components\Textarea::make('description') ->rows(3), + Forms\Components\MarkdownEditor::make('notes') + ->label('Lesson notes') + ->helperText('Markdown — commands, code samples, links, etc. Shown beneath the video.') + ->columnSpanFull(), + Forms\Components\TextInput::make('vimeo_id') ->label('Vimeo ID') ->maxLength(255), diff --git a/app/Livewire/Customer/Course/Index.php b/app/Livewire/Customer/Course/Index.php index adcc496e..dafbb52d 100644 --- a/app/Livewire/Customer/Course/Index.php +++ b/app/Livewire/Customer/Course/Index.php @@ -29,9 +29,7 @@ public function course(): ?Course ->with(['modules' => function ($query) { $query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true)) ->orderBy('sort_order') - ->with(['lessons' => function ($query) { - $query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))->orderBy('sort_order'); - }]); + ->with(['lessons' => fn ($query) => $query->orderBy('sort_order')]); }]) ->first(); } @@ -94,6 +92,14 @@ public function completedLessonIds(): array ->all(); } + #[Computed] + public function hasVideoLessons(): bool + { + return (bool) $this->course?->modules + ->flatMap(fn ($module) => $module->lessons) + ->contains(fn ($lesson) => filled($lesson->vimeo_id) && ($lesson->is_published || $this->isAdmin)); + } + #[Computed] public function totalLessons(): int { @@ -101,7 +107,9 @@ public function totalLessons(): int return 0; } - return $this->course->modules->sum(fn ($module) => $module->lessons->count()); + return $this->course->modules->sum( + fn ($module) => $module->lessons->filter(fn ($lesson) => $lesson->is_published)->count() + ); } #[Computed] diff --git a/app/Livewire/Customer/Course/LessonShow.php b/app/Livewire/Customer/Course/LessonShow.php index 6f563945..6ce0e012 100644 --- a/app/Livewire/Customer/Course/LessonShow.php +++ b/app/Livewire/Customer/Course/LessonShow.php @@ -6,25 +6,41 @@ use App\Models\CourseLesson; use App\Models\LessonProgress; use App\Models\Product; -use Illuminate\Database\Eloquent\Collection; use Livewire\Attributes\Computed; use Livewire\Attributes\Layout; +use Livewire\Attributes\Renderless; use Livewire\Component; #[Layout('components.layouts.dashboard')] class LessonShow extends Component { + public const INTRO_SECONDS = 9; + + public const OUTRO_SECONDS = 10; + + private const VIDEO_PLAYED_SESSION_KEY = 'course_video_played'; + public CourseLesson $lesson; + public bool $skipIntroOutro = false; + public function mount(CourseLesson $lesson): void { $this->lesson = $lesson->load('module.course'); - abort_unless($this->lesson->is_published || $this->isAdmin, 404); + 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.'); } + + $this->skipIntroOutro = session()->has(self::VIDEO_PLAYED_SESSION_KEY); + } + + #[Renderless] + public function markVideoPlayed(): void + { + session()->put(self::VIDEO_PLAYED_SESSION_KEY, true); } #[Computed] @@ -39,9 +55,7 @@ public function course(): Course return $this->lesson->module->course->load(['modules' => function ($query) { $query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true)) ->orderBy('sort_order') - ->with(['lessons' => function ($query) { - $query->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true))->orderBy('sort_order'); - }]); + ->with(['lessons' => fn ($query) => $query->orderBy('sort_order')]); }]); } @@ -53,17 +67,6 @@ public function hasPurchased(): bool return $product && $product->isOwnedBy(auth()->user()); } - /** - * @return Collection - */ - #[Computed] - public function moduleLessons(): Collection - { - return $this->lesson->module->lessons() - ->when(! $this->isAdmin, fn ($query) => $query->where('is_published', true)) - ->get(); - } - #[Computed] public function completedLessonIds(): array { @@ -117,12 +120,23 @@ public function toggleComplete(): void public function render() { - return view('livewire.customer.course.lesson-show') - ->title($this->lesson->title); + return view('livewire.customer.course.lesson-show', [ + 'introSkipSeconds' => self::INTRO_SECONDS, + 'outroSkipSeconds' => self::OUTRO_SECONDS, + ])->title($this->lesson->title); } private function orderedLessons() { - return $this->course->modules->flatMap(fn ($m) => $m->lessons); + return $this->course->modules + ->flatMap(fn ($module) => $module->lessons) + ->filter(fn (CourseLesson $lesson) => $this->canNavigateTo($lesson)) + ->values(); + } + + private function canNavigateTo(CourseLesson $lesson): bool + { + return ($lesson->is_published || $this->isAdmin) + && ($lesson->is_free || $this->hasPurchased || $this->isAdmin); } } diff --git a/database/factories/CourseLessonFactory.php b/database/factories/CourseLessonFactory.php index 1470212d..3d3376e9 100644 --- a/database/factories/CourseLessonFactory.php +++ b/database/factories/CourseLessonFactory.php @@ -23,6 +23,7 @@ public function definition(): array 'title' => $title, 'slug' => Str::slug($title), 'description' => fake()->paragraph(), + 'notes' => null, 'vimeo_id' => null, 'duration_in_seconds' => fake()->numberBetween(60, 1800), 'is_free' => false, diff --git a/database/migrations/2026_07_24_114627_add_notes_to_course_lessons_table.php b/database/migrations/2026_07_24_114627_add_notes_to_course_lessons_table.php new file mode 100644 index 00000000..c99d3419 --- /dev/null +++ b/database/migrations/2026_07_24_114627_add_notes_to_course_lessons_table.php @@ -0,0 +1,28 @@ +text('notes')->nullable()->after('description'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('course_lessons', function (Blueprint $table) { + $table->dropColumn('notes'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index f9b0a34f..bb6d8b7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "nativephp.com", + "name": "bright-duck", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/resources/js/alpine/courseVideo.js b/resources/js/alpine/courseVideo.js new file mode 100644 index 00000000..3e3df442 --- /dev/null +++ b/resources/js/alpine/courseVideo.js @@ -0,0 +1,123 @@ +const VIMEO_SDK_URL = 'https://player.vimeo.com/api/player.js' + +let sdkPromise = null + +function loadVimeoSdk() { + if (window.Vimeo?.Player) { + return Promise.resolve() + } + + if (!sdkPromise) { + sdkPromise = new Promise((resolve, reject) => { + const script = document.createElement('script') + script.src = VIMEO_SDK_URL + script.async = true + script.onload = resolve + script.onerror = () => + reject(new Error('Unable to load the Vimeo player SDK')) + document.head.appendChild(script) + }) + } + + return sdkPromise +} + +export default (options = {}) => ({ + videoId: options.videoId, + skip: options.skip ?? false, + introSeconds: options.introSeconds ?? 9, + outroSeconds: options.outroSeconds ?? 10, + marked: false, + cleanup: null, + + init() { + loadVimeoSdk() + .then(() => this.setupPlayer()) + .catch(() => {}) + }, + + setupPlayer() { + if (!window.Vimeo?.Player || !this.videoId) { + return + } + + // Keep the player in a plain closure variable, never a reactive Alpine + // property: the SDK keys its ready-promise and event registry off the raw + // instance via WeakMaps, and an Alpine proxy breaks every lookup + // ("Unknown player. Probably unloaded."). + const player = new window.Vimeo.Player(this.$el, { + id: Number(this.videoId), + autopause: false, + badge: false, + }) + + player.on('play', () => this.rememberFirstPlay()) + + if (this.skip) { + player.on('loaded', () => + player.setCurrentTime(this.introSeconds).catch(() => {}), + ) + player + .getDuration() + .then((duration) => { + const cutoff = duration - this.outroSeconds + + if (cutoff <= this.introSeconds) { + return + } + + player.on('timeupdate', ({ seconds }) => { + if (seconds >= cutoff) { + player.pause().catch(() => {}) + } + }) + }) + .catch(() => {}) + } + + // Toggle play/pause with the space bar anywhere on the page. When the + // Vimeo iframe itself is focused the keydown fires inside it (and Vimeo + // handles space natively), so this never double-fires. + const onKeydown = (e) => { + if (e.code !== 'Space' || e.repeat) { + return + } + + const tag = e.target?.tagName + if ( + e.target?.isContentEditable || + tag === 'INPUT' || + tag === 'TEXTAREA' || + tag === 'SELECT' || + tag === 'BUTTON' + ) { + return + } + + e.preventDefault() + player + .getPaused() + .then((paused) => (paused ? player.play() : player.pause())) + .catch(() => {}) + } + window.addEventListener('keydown', onKeydown) + + this.cleanup = () => { + window.removeEventListener('keydown', onKeydown) + player.destroy().catch(() => {}) + } + }, + + rememberFirstPlay() { + if (this.skip || this.marked) { + return + } + + this.marked = true + this.$wire.markVideoPlayed() + }, + + destroy() { + this.cleanup?.() + }, +}) diff --git a/resources/js/app.js b/resources/js/app.js index dfb5cda0..12430840 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -8,6 +8,7 @@ import { } from '../../vendor/livewire/livewire/dist/livewire.esm.js' import codeBlock from './alpine/codeBlock.js' import copyMarkdown from './alpine/copyMarkdown.js' +import courseVideo from './alpine/courseVideo.js' import sidebarGroup from './alpine/sidebarGroup.js' import docsearch from '@docsearch/js' import Atropos from 'atropos' @@ -63,6 +64,7 @@ window.gsap = gsap // Alpine Alpine.data('codeBlock', codeBlock) Alpine.data('copyMarkdown', copyMarkdown) +Alpine.data('courseVideo', courseVideo) Alpine.data('sidebarGroup', sidebarGroup) Alpine.magic('refAll', (el) => { return (refName) => { diff --git a/resources/views/components/layouts/dashboard.blade.php b/resources/views/components/layouts/dashboard.blade.php index aebf5397..ae2f5548 100644 --- a/resources/views/components/layouts/dashboard.blade.php +++ b/resources/views/components/layouts/dashboard.blade.php @@ -186,22 +186,10 @@ class="min-h-screen bg-white font-poppins antialiased dark:bg-zinc-900 dark:text @endfeature @feature(App\Features\ShowMasterclass::class) - @php - $sidebarCourse = \App\Models\Course::where('is_published', true) - ->with(['modules' => fn ($q) => $q->where('is_published', true)->orderBy('sort_order')]) - ->first(); - @endphp Mobile - @if($sidebarCourse) - @foreach($sidebarCourse->modules as $sidebarModule) - - {{ $sidebarModule->title }} - - @endforeach - @endif @endfeature diff --git a/resources/views/livewire/customer/course.blade.php b/resources/views/livewire/customer/course.blade.php index 24bc8498..6ad2a3fb 100644 --- a/resources/views/livewire/customer/course.blade.php +++ b/resources/views/livewire/customer/course.blade.php @@ -22,21 +22,23 @@ class="h-2 rounded-full bg-emerald-500 transition-all" @endif -
-
-
- -
-
-

- You're in! Course content is coming soon. -

-

- We're recording the lessons now. You'll have full access to all modules and lessons as soon as they're published. -

+ @unless ($this->hasVideoLessons) +
+
+
+ +
+
+

+ You're in! Course content is coming soon. +

+

+ We're recording the lessons now. You'll have full access to all modules and lessons as soon as they're published. +

+
-
+ @endunless {{-- Modules --}}
@@ -49,11 +51,6 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
{{ $module->title }} - @if ($module->is_free) - Free - @else - Pro - @endif @if (! $module->is_published) Draft @endif @@ -68,21 +65,21 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
@foreach ($module->lessons as $lesson) @php - $isCompleted = in_array($lesson->id, $this->completedLessonIds); + $isDraft = ! $lesson->is_published; + $isClickable = $this->isAdmin || ! $isDraft; @endphp
-
- @if ($isCompleted) - - @endif -
- - {{ $lesson->title }} - + @if ($isClickable) + + {{ $lesson->title }} + + @else + {{ $lesson->title }} + @endif
- @if (! $lesson->is_published) - Draft + @if ($isDraft) + Coming Soon @endif @if ($lesson->duration_in_seconds) {{ gmdate('i:s', $lesson->duration_in_seconds) }} diff --git a/resources/views/livewire/customer/course/lesson-show.blade.php b/resources/views/livewire/customer/course/lesson-show.blade.php index 30a47ce2..f54d04f1 100644 --- a/resources/views/livewire/customer/course/lesson-show.blade.php +++ b/resources/views/livewire/customer/course/lesson-show.blade.php @@ -1,117 +1,137 @@ -
- {{-- Video area — full width --}} -
- @if ($lesson->vimeo_id) - - @else -
-
- +
+ {{-- Main column --}} +
+ {{-- Video area --}} +
+ @if ($lesson->vimeo_id) +
+ @else +
+
+ +
+

Video coming soon

-

Video coming soon

-
- @endif -
+ @endif +
- {{-- Lesson info --}} -
-
-
-
- @if ($lesson->is_free) - Free - @else - Pro - @endif - @if (! $lesson->is_published) - Draft + {{-- Lesson info --}} +
+
+
+
+ @if (! $lesson->is_published) + Coming Soon + @endif + {{ $lesson->module->title }} + @if (! $lesson->module->is_published) + Draft + @endif +
+ {{ $lesson->title }} + @if ($lesson->description) + {{ $lesson->description }} @endif - {{ $lesson->module->title }}
- {{ $lesson->title }} - @if ($lesson->description) - {{ $lesson->description }} - @endif + +
- -
+ @if ($this->nextLesson) + + Next + + + @endif +
- {{-- Prev / Next nav --}} -
- @if ($this->previousLesson) - - - Previous - - @endif - @if ($this->nextLesson) - - Next - - + {{-- Lesson notes --}} + @if ($lesson->notes) +
+ {!! App\Support\CommonMark\CommonMark::convertToHtml($lesson->notes) !!} +
@endif
- {{-- Other lessons in this module --}} - @if ($this->moduleLessons->count() > 1) -
+ {{-- Course outline --}} +
diff --git a/tests/Feature/CourseContentTest.php b/tests/Feature/CourseContentTest.php index 4cfca403..4c3b7365 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -301,11 +301,12 @@ public function admin_sees_unpublished_course_content_in_dashboard(): void ->test(Index::class) ->assertSee('Hidden Module') ->assertSee('Hidden Lesson') + ->assertSee('Coming Soon') ->assertSee('Draft'); } #[Test] - public function non_admin_owner_does_not_see_unpublished_content_in_dashboard(): void + public function non_admin_owner_sees_draft_lessons_as_coming_soon_but_not_unpublished_modules(): void { $user = User::factory()->create(); $product = Product::where('slug', 'nativephp-masterclass')->first(); @@ -319,9 +320,9 @@ public function non_admin_owner_does_not_see_unpublished_content_in_dashboard(): 'course_id' => $course->id, 'title' => 'Live Module', ]); - CourseLesson::factory()->create([ + $draftLesson = CourseLesson::factory()->create([ 'course_module_id' => $liveModule->id, - 'title' => 'Hidden Lesson', + 'title' => 'Draft Lesson', ]); CourseModule::factory()->create([ 'course_id' => $course->id, @@ -331,7 +332,9 @@ public function non_admin_owner_does_not_see_unpublished_content_in_dashboard(): Livewire::actingAs($user) ->test(Index::class) ->assertSee('Live Module') - ->assertDontSee('Hidden Lesson') + ->assertSee('Draft Lesson') + ->assertSee('Coming Soon') + ->assertDontSee(route('customer.course.lesson', $draftLesson), false) ->assertDontSee('Hidden Module'); } @@ -352,6 +355,7 @@ public function admin_can_view_unpublished_pro_lesson_without_purchase(): void Livewire::actingAs($admin) ->test(LessonShow::class, ['lesson' => $lesson]) ->assertSee('Hidden Pro Lesson') + ->assertSee('Coming Soon') ->assertSee('Draft'); } @@ -393,4 +397,368 @@ public function module_has_lessons_relationship(): void $this->assertTrue($module->lessons->contains($lesson)); } + + #[Test] + public function first_video_in_a_session_plays_the_intro_in_full(): void + { + $lesson = $this->publishedVideoLesson('111111'); + + Livewire::actingAs(User::factory()->create()) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertSet('skipIntroOutro', false) + ->assertSee('skip: false', false); + } + + #[Test] + public function playing_a_video_records_it_in_the_session(): void + { + $lesson = $this->publishedVideoLesson('222222'); + + Livewire::actingAs(User::factory()->create()) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->call('markVideoPlayed'); + + $this->assertTrue(session()->has('course_video_played')); + } + + #[Test] + public function subsequent_videos_skip_the_intro_and_outro(): void + { + session()->put('course_video_played', true); + + $lesson = $this->publishedVideoLesson('333333'); + + Livewire::actingAs(User::factory()->create()) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertSet('skipIntroOutro', true) + ->assertSee('skip: true', false); + } + + #[Test] + public function playing_the_first_video_makes_later_videos_in_the_session_skip(): void + { + $user = User::factory()->create(); + $firstLesson = $this->publishedVideoLesson('444444'); + $secondLesson = $this->publishedVideoLesson('555555'); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $firstLesson]) + ->assertSet('skipIntroOutro', false) + ->call('markVideoPlayed'); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $secondLesson]) + ->assertSet('skipIntroOutro', true) + ->assertSee('skip: true', false); + } + + #[Test] + public function lesson_page_shows_the_full_course_outline(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $currentModule = CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'title' => 'Getting Started', + 'sort_order' => 1, + ]); + $otherModule = CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'title' => 'Going Deeper', + 'sort_order' => 2, + ]); + $currentLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $currentModule->id, + 'title' => 'Intro Lesson', + ]); + CourseLesson::factory()->published()->create([ + 'course_module_id' => $otherModule->id, + 'title' => 'Advanced Lesson', + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $currentLesson]) + ->assertSee('Course outline') + ->assertSee('Getting Started') + ->assertSee('Going Deeper') + ->assertSee('Advanced Lesson'); + } + + #[Test] + public function coming_soon_banner_shows_when_no_lessons_have_videos(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'vimeo_id' => null, + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('recording the lessons now'); + } + + #[Test] + public function coming_soon_banner_is_hidden_when_a_lesson_has_a_video(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'vimeo_id' => '123456', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertDontSee('recording the lessons now'); + } + + #[Test] + public function course_module_list_does_not_show_free_or_pro_pills(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + CourseModule::factory()->published()->free()->create([ + 'course_id' => $course->id, + 'title' => 'Starter Module', + 'description' => null, + ]); + CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'title' => 'Advanced Module', + 'description' => null, + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('Starter Module') + ->assertSee('Advanced Module') + ->assertDontSee('Free') + ->assertDontSee('Pro'); + } + + #[Test] + public function draft_lessons_show_in_the_outline_as_coming_soon_but_are_not_clickable_for_non_admins(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $currentLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Current Lesson', + ]); + $draftLesson = CourseLesson::factory()->create([ + 'course_module_id' => $module->id, + 'title' => 'Draft Lesson', + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $currentLesson]) + ->assertSee('Draft Lesson') + ->assertSee('Coming Soon') + ->assertDontSee(route('customer.course.lesson', $draftLesson), false); + } + + #[Test] + public function draft_lessons_are_clickable_in_the_outline_for_admins(): void + { + config(['filament.users' => ['admin@test.com']]); + $admin = User::factory()->create(['email' => 'admin@test.com']); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $currentLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Current Lesson', + ]); + $draftLesson = CourseLesson::factory()->create([ + 'course_module_id' => $module->id, + 'title' => 'Draft Lesson', + ]); + + Livewire::actingAs($admin) + ->test(LessonShow::class, ['lesson' => $currentLesson]) + ->assertSee('Draft Lesson') + ->assertSee('Coming Soon') + ->assertSee(route('customer.course.lesson', $draftLesson), false); + } + + #[Test] + public function draft_video_lessons_do_not_hide_the_coming_soon_banner_for_non_admins(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + CourseLesson::factory()->create([ + 'course_module_id' => $module->id, + 'vimeo_id' => '123456', + ]); + + Livewire::actingAs($user) + ->test(Index::class) + ->assertSee('recording the lessons now'); + } + + #[Test] + public function completed_lessons_are_struck_through_in_the_outline(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $currentLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Current Lesson', + ]); + $doneLesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Done Lesson', + ]); + LessonProgress::create([ + 'user_id' => $user->id, + 'course_lesson_id' => $doneLesson->id, + 'completed_at' => now(), + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $currentLesson]) + ->assertSee('Done Lesson') + ->assertSeeHtml('line-through'); + } + + #[Test] + public function outline_lesson_titles_have_a_hover_title_attribute_and_are_not_struck_through_when_incomplete(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Hover For Full Title', + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertSeeHtml('title="Hover For Full Title"') + ->assertDontSeeHtml('line-through'); + } + + #[Test] + public function lessons_in_an_unpublished_module_are_not_accessible_to_non_admins(): void + { + $user = User::factory()->create(); + $product = Product::where('slug', 'nativephp-masterclass')->first(); + ProductLicense::factory()->create([ + 'user_id' => $user->id, + 'product_id' => $product->id, + ]); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertNotFound(); + } + + #[Test] + public function admins_can_access_lessons_in_unpublished_modules(): void + { + config(['filament.users' => ['admin@test.com']]); + $admin = User::factory()->create(['email' => 'admin@test.com']); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->create([ + 'course_module_id' => $module->id, + 'title' => 'Locked Away Lesson', + ]); + + Livewire::actingAs($admin) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertOk() + ->assertSee('Locked Away Lesson'); + } + + #[Test] + public function lesson_notes_are_rendered_as_markdown(): void + { + $user = User::factory()->create(); + + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create(['course_id' => $course->id]); + $lesson = CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'notes' => 'Run `php artisan migrate` then read [the docs](https://nativephp.com).', + ]); + + Livewire::actingAs($user) + ->test(LessonShow::class, ['lesson' => $lesson]) + ->assertSee('php artisan migrate', false) + ->assertSee('href="https://nativephp.com"', false); + } + + private function publishedVideoLesson(string $vimeoId): CourseLesson + { + $course = Course::factory()->published()->create(); + $module = CourseModule::factory()->published()->free()->create(['course_id' => $course->id]); + + return CourseLesson::factory()->published()->free()->create([ + 'course_module_id' => $module->id, + 'vimeo_id' => $vimeoId, + ]); + } } diff --git a/tests/Feature/DashboardLayoutTest.php b/tests/Feature/DashboardLayoutTest.php index 2fc507a1..72cc1746 100644 --- a/tests/Feature/DashboardLayoutTest.php +++ b/tests/Feature/DashboardLayoutTest.php @@ -6,6 +6,8 @@ use App\Features\ShowMasterclass; use App\Features\ShowPlugins; use App\Livewire\Customer\Dashboard; +use App\Models\Course; +use App\Models\CourseModule; use App\Models\License; use App\Models\Product; use App\Models\ProductLicense; @@ -266,4 +268,22 @@ public function test_dashboard_hides_masterclass_countdown_for_course_owner(): v ->assertOk() ->assertDontSee('Lock in early bird pricing'); } + + public function test_sidebar_masterclass_group_does_not_list_module_titles(): void + { + Feature::define(ShowMasterclass::class, true); + + $user = User::factory()->create(); + $course = Course::factory()->published()->create(); + CourseModule::factory()->published()->create([ + 'course_id' => $course->id, + 'title' => 'Sidebar Should Not List This Module', + ]); + + $response = $this->withoutVite()->actingAs($user)->get('/dashboard'); + + $response->assertOk(); + $response->assertSee('Masterclass'); + $response->assertDontSee('Sidebar Should Not List This Module'); + } }