From ed0b14e88fe848b57797fe1292c3f4f54652d53f Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Thu, 23 Jul 2026 20:09:04 +0100 Subject: [PATCH 1/3] Skip course video intro/outro after first play per session Show the branded intro (first 9s) once per session: the first video a user plays runs in full, then every later video that session starts at 9s and stops 10s before the end. A session flag on LessonShow drives whether to skip; a new courseVideo Alpine component uses the Vimeo Player SDK to detect the first play, mark the session, and pause at duration-10s for the outro. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Customer/Course/LessonShow.php | 23 ++++- package-lock.json | 2 +- resources/js/alpine/courseVideo.js | 83 +++++++++++++++++++ resources/js/app.js | 2 + .../customer/course/lesson-show.blade.php | 4 +- tests/Feature/CourseContentTest.php | 65 +++++++++++++++ 6 files changed, 175 insertions(+), 4 deletions(-) create mode 100644 resources/js/alpine/courseVideo.js diff --git a/app/Livewire/Customer/Course/LessonShow.php b/app/Livewire/Customer/Course/LessonShow.php index 6f563945..0a249f29 100644 --- a/app/Livewire/Customer/Course/LessonShow.php +++ b/app/Livewire/Customer/Course/LessonShow.php @@ -9,13 +9,22 @@ 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'); @@ -25,6 +34,14 @@ public function mount(CourseLesson $lesson): void 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] @@ -117,8 +134,10 @@ 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() 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..e8758ff1 --- /dev/null +++ b/resources/js/alpine/courseVideo.js @@ -0,0 +1,83 @@ +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 = {}) => ({ + skip: options.skip ?? false, + introSeconds: options.introSeconds ?? 9, + outroSeconds: options.outroSeconds ?? 10, + player: null, + marked: false, + + init() { + loadVimeoSdk() + .then(() => this.setupPlayer()) + .catch(() => {}) + }, + + setupPlayer() { + if (!window.Vimeo?.Player) { + return + } + + this.player = new window.Vimeo.Player(this.$el) + + this.player.on('play', () => this.rememberFirstPlay()) + + if (this.skip) { + this.skipOutro() + } + }, + + rememberFirstPlay() { + if (this.skip || this.marked) { + return + } + + this.marked = true + this.$wire.markVideoPlayed() + }, + + skipOutro() { + this.player + .getDuration() + .then((duration) => { + const cutoff = duration - this.outroSeconds + + if (cutoff <= this.introSeconds) { + return + } + + this.player.on('timeupdate', ({ seconds }) => { + if (seconds >= cutoff) { + this.player.pause().catch(() => {}) + } + }) + }) + .catch(() => {}) + }, + + destroy() { + this.player?.unload().catch(() => {}) + }, +}) 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/livewire/customer/course/lesson-show.blade.php b/resources/views/livewire/customer/course/lesson-show.blade.php index 30a47ce2..73a94b68 100644 --- a/resources/views/livewire/customer/course/lesson-show.blade.php +++ b/resources/views/livewire/customer/course/lesson-show.blade.php @@ -3,7 +3,9 @@
@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_free) + Free + @else + Pro + @endif + @if (! $lesson->is_published) + Coming Soon + @endif + {{ $lesson->module->title }} +
+ {{ $lesson->title }} + @if ($lesson->description) + {{ $lesson->description }} @endif - {{ $lesson->module->title }}
- {{ $lesson->title }} - @if ($lesson->description) - {{ $lesson->description }} - @endif + +
- -
- - {{-- Prev / Next nav --}} -
- @if ($this->previousLesson) - - - Previous - - @endif - @if ($this->nextLesson) - - Next - - - @endif + @if ($this->nextLesson) + + Next + + + @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 d8d1d160..fa8bfb62 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -301,11 +301,11 @@ public function admin_sees_unpublished_course_content_in_dashboard(): void ->test(Index::class) ->assertSee('Hidden Module') ->assertSee('Hidden Lesson') - ->assertSee('Draft'); + ->assertSee('Coming Soon'); } #[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 +319,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 +331,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,7 +354,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('Draft'); + ->assertSee('Coming Soon'); } #[Test] @@ -448,6 +450,247 @@ public function playing_the_first_video_makes_later_videos_in_the_session_skip() ->assertSee('#t=9s', 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'); + } + private function publishedVideoLesson(string $vimeoId): CourseLesson { $course = Course::factory()->published()->create(); 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'); + } } From 456b6badb9f691bab2e04a45c24dd62e8615edde Mon Sep 17 00:00:00 2001 From: Simon Hamp Date: Fri, 24 Jul 2026 13:39:19 +0100 Subject: [PATCH 3/3] Fix course video skip and add markdown lesson notes - Fix the intro/outro skip: the Vimeo player was held on a reactive Alpine property, whose proxy broke the SDK's WeakMap lookups ("Unknown player"). Keep the player in a closure and let the SDK build the iframe; the intro skip moves to setCurrentTime - Toggle play/pause with the space bar anywhere on the lesson page - Add markdown lesson notes: a MarkdownEditor field in the admin (like blog articles) plus a notes column, rendered beneath the Prev/Next row via CommonMark in a fixed-width left-aligned column - Hide unpublished modules from non-admins and block access to their lessons; label unpublished modules "Draft" - Remove the Free/Pro labels from the lesson page Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Resources/CourseLessonResource.php | 6 ++ .../LessonsRelationManager.php | 5 ++ app/Livewire/Customer/Course/LessonShow.php | 2 +- database/factories/CourseLessonFactory.php | 1 + ...4627_add_notes_to_course_lessons_table.php | 28 ++++++ resources/js/alpine/courseVideo.js | 90 +++++++++++++------ .../views/livewire/customer/course.blade.php | 2 +- .../customer/course/lesson-show.blade.php | 29 +++--- tests/Feature/CourseContentTest.php | 70 +++++++++++++-- 9 files changed, 187 insertions(+), 46 deletions(-) create mode 100644 database/migrations/2026_07_24_114627_add_notes_to_course_lessons_table.php 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/LessonShow.php b/app/Livewire/Customer/Course/LessonShow.php index 3ba16426..6ce0e012 100644 --- a/app/Livewire/Customer/Course/LessonShow.php +++ b/app/Livewire/Customer/Course/LessonShow.php @@ -28,7 +28,7 @@ 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.'); 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/resources/js/alpine/courseVideo.js b/resources/js/alpine/courseVideo.js index e8758ff1..3e3df442 100644 --- a/resources/js/alpine/courseVideo.js +++ b/resources/js/alpine/courseVideo.js @@ -23,11 +23,12 @@ function loadVimeoSdk() { } export default (options = {}) => ({ + videoId: options.videoId, skip: options.skip ?? false, introSeconds: options.introSeconds ?? 9, outroSeconds: options.outroSeconds ?? 10, - player: null, marked: false, + cleanup: null, init() { loadVimeoSdk() @@ -36,16 +37,74 @@ export default (options = {}) => ({ }, setupPlayer() { - if (!window.Vimeo?.Player) { + if (!window.Vimeo?.Player || !this.videoId) { return } - this.player = new window.Vimeo.Player(this.$el) + // 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, + }) - this.player.on('play', () => this.rememberFirstPlay()) + player.on('play', () => this.rememberFirstPlay()) if (this.skip) { - this.skipOutro() + 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(() => {}) } }, @@ -58,26 +117,7 @@ export default (options = {}) => ({ this.$wire.markVideoPlayed() }, - skipOutro() { - this.player - .getDuration() - .then((duration) => { - const cutoff = duration - this.outroSeconds - - if (cutoff <= this.introSeconds) { - return - } - - this.player.on('timeupdate', ({ seconds }) => { - if (seconds >= cutoff) { - this.player.pause().catch(() => {}) - } - }) - }) - .catch(() => {}) - }, - destroy() { - this.player?.unload().catch(() => {}) + this.cleanup?.() }, }) diff --git a/resources/views/livewire/customer/course.blade.php b/resources/views/livewire/customer/course.blade.php index e9b1ac7c..6ad2a3fb 100644 --- a/resources/views/livewire/customer/course.blade.php +++ b/resources/views/livewire/customer/course.blade.php @@ -52,7 +52,7 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
{{ $module->title }} @if (! $module->is_published) - Coming Soon + Draft @endif
@if ($module->description) diff --git a/resources/views/livewire/customer/course/lesson-show.blade.php b/resources/views/livewire/customer/course/lesson-show.blade.php index fe0ed130..f54d04f1 100644 --- a/resources/views/livewire/customer/course/lesson-show.blade.php +++ b/resources/views/livewire/customer/course/lesson-show.blade.php @@ -4,15 +4,11 @@ {{-- Video area --}}
@if ($lesson->vimeo_id) - + x-data="courseVideo({ videoId: '{{ $lesson->vimeo_id }}', skip: @js($skipIntroOutro), introSeconds: {{ $introSkipSeconds }}, outroSeconds: {{ $outroSkipSeconds }} })" + class="absolute inset-0 [&_iframe]:absolute [&_iframe]:inset-0 [&_iframe]:h-full [&_iframe]:w-full" + >
@else
@@ -28,15 +24,13 @@ class="absolute inset-0 w-full h-full"
- @if ($lesson->is_free) - Free - @else - Pro - @endif @if (! $lesson->is_published) Coming Soon @endif {{ $lesson->module->title }} + @if (! $lesson->module->is_published) + Draft + @endif
{{ $lesson->title }} @if ($lesson->description) @@ -73,6 +67,13 @@ class="shrink-0 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibo @endif
+ + {{-- Lesson notes --}} + @if ($lesson->notes) +
+ {!! App\Support\CommonMark\CommonMark::convertToHtml($lesson->notes) !!} +
+ @endif
@@ -88,7 +89,7 @@ class="shrink-0 flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-semibo
{{ $outlineModule->title }} @if (! $outlineModule->is_published) - Coming Soon + Draft @endif
@foreach ($outlineModule->lessons as $outlineLesson) diff --git a/tests/Feature/CourseContentTest.php b/tests/Feature/CourseContentTest.php index fa8bfb62..4c3b7365 100644 --- a/tests/Feature/CourseContentTest.php +++ b/tests/Feature/CourseContentTest.php @@ -301,7 +301,8 @@ public function admin_sees_unpublished_course_content_in_dashboard(): void ->test(Index::class) ->assertSee('Hidden Module') ->assertSee('Hidden Lesson') - ->assertSee('Coming Soon'); + ->assertSee('Coming Soon') + ->assertSee('Draft'); } #[Test] @@ -354,7 +355,8 @@ 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('Coming Soon') + ->assertSee('Draft'); } #[Test] @@ -404,7 +406,7 @@ public function first_video_in_a_session_plays_the_intro_in_full(): void Livewire::actingAs(User::factory()->create()) ->test(LessonShow::class, ['lesson' => $lesson]) ->assertSet('skipIntroOutro', false) - ->assertDontSee('#t=9s', false); + ->assertSee('skip: false', false); } #[Test] @@ -429,7 +431,7 @@ public function subsequent_videos_skip_the_intro_and_outro(): void Livewire::actingAs(User::factory()->create()) ->test(LessonShow::class, ['lesson' => $lesson]) ->assertSet('skipIntroOutro', true) - ->assertSee('#t=9s', false); + ->assertSee('skip: true', false); } #[Test] @@ -447,7 +449,7 @@ public function playing_the_first_video_makes_later_videos_in_the_session_skip() Livewire::actingAs($user) ->test(LessonShow::class, ['lesson' => $secondLesson]) ->assertSet('skipIntroOutro', true) - ->assertSee('#t=9s', false); + ->assertSee('skip: true', false); } #[Test] @@ -691,6 +693,64 @@ public function outline_lesson_titles_have_a_hover_title_attribute_and_are_not_s ->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();