Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions app/Livewire/Customer/Course/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<int, CourseModule>
*/
#[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;
}
}
6 changes: 5 additions & 1 deletion app/Livewire/Customer/Course/LessonShow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
95 changes: 91 additions & 4 deletions resources/views/livewire/customer/course.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
@endif
@else
{{-- Not purchased: Full-width marketing/purchase page --}}
<div class="course-fullbleed">
<div class="course-fullbleed pb-6 lg:pb-8">
{{-- Hero --}}
<div class="relative overflow-hidden bg-gradient-to-b from-violet-50 to-white px-6 py-16 sm:px-12 sm:py-20 dark:from-zinc-900 dark:to-zinc-950">
{{-- Background glow --}}
Expand All @@ -109,9 +109,9 @@ class="h-2 rounded-full bg-emerald-500 transition-all"
<div class="relative z-10 mx-auto max-w-2xl text-center">
<span class="inline-flex items-center gap-2 rounded-md bg-violet-500/10 px-3 py-1 text-xs font-bold uppercase tracking-widest text-violet-600 ring-1 ring-violet-500/20 dark:text-violet-400">
@if ($this->priceIncreased)
New Course
Masterclass
@else
New Course &mdash; Early Bird
Masterclass &mdash; Early Bird
@endif
</span>

Expand Down Expand Up @@ -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-
</div>

{{-- What's Included --}}
<div class="mx-auto mt-8 grid max-w-7xl gap-4 px-6 pb-6 sm:grid-cols-2 lg:grid-cols-4 lg:px-8 lg:pb-8">
<div class="mx-auto mt-8 grid max-w-7xl gap-4 px-6 sm:grid-cols-2 lg:grid-cols-4 lg:px-8">
<div class="rounded-xl border border-zinc-200 bg-white p-5 dark:border-white/10 dark:bg-white/5">
<div class="flex size-9 items-center justify-center rounded-lg bg-violet-100 dark:bg-violet-500/15">
<x-heroicon-s-device-phone-mobile class="size-4 text-violet-600 dark:text-violet-400" />
Expand Down Expand Up @@ -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-
<p class="mt-1 text-xs text-zinc-600 dark:text-zinc-400">One-time payment. All current and future content.</p>
</div>
</div>

{{-- Locked lesson notice --}}
@if (session('message'))
<div class="mx-auto mt-8 max-w-7xl px-6 lg:px-8">
<flux:callout variant="secondary" icon="lock-closed">
<flux:callout.text>{{ session('message') }}</flux:callout.text>
</flux:callout>
</div>
@endif

{{-- Course curriculum --}}
@if ($this->outlineModules->isNotEmpty())
<div class="mx-auto mt-8 max-w-7xl px-6 lg:px-8">
<div class="flex items-center gap-3">
<flux:heading size="lg">Course curriculum</flux:heading>
@if ($this->hasFreeLessons)
<flux:badge variant="pill" color="emerald" size="sm">Free lessons included</flux:badge>
@endif
</div>
<flux:text class="mt-1 text-sm">
@if ($this->hasFreeLessons)
Start with the lessons you can watch right now &mdash; everything else unlocks when you get the course.
@else
Every lesson unlocks when you get the course.
@endif
</flux:text>

<div class="mt-4 space-y-4">
@foreach ($this->outlineModules as $moduleIndex => $module)
@php
$publishedLessons = $module->lessons->where('is_published', true);
$moduleHasFreeLesson = $publishedLessons->contains(fn ($moduleLesson) => $this->isFreePreviewLesson($moduleLesson));
@endphp
<div wire:key="outline-module-{{ $module->id }}" class="rounded-lg border border-zinc-200 bg-white dark:border-white/10 dark:bg-white/5">
<div class="flex items-center gap-4 p-4">
<div class="flex size-10 shrink-0 items-center justify-center rounded-lg text-sm font-bold {{ $moduleHasFreeLesson ? 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/50 dark:text-emerald-400' : 'bg-zinc-100 text-zinc-500 dark:bg-white/5 dark:text-zinc-500' }}">
{{ str_pad($moduleIndex + 1, 2, '0', STR_PAD_LEFT) }}
</div>
<div class="min-w-0 flex-1">
<flux:heading size="sm">{{ $module->title }}</flux:heading>
@if ($module->description)
<flux:text class="mt-1 text-sm">{{ $module->description }}</flux:text>
@endif
</div>
</div>

<div class="border-t border-zinc-200 dark:border-white/10">
@foreach ($publishedLessons as $lesson)
@php
$isPreviewable = $this->isFreePreviewLesson($lesson);
@endphp
<div wire:key="outline-lesson-{{ $lesson->id }}" class="flex items-center gap-3 px-4 py-3 {{ ! $loop->last ? 'border-b border-zinc-100 dark:border-white/5' : '' }}">
{{-- Fixed-width slot keeps titles aligned whether or not a lock is shown --}}
<div class="flex size-4 shrink-0 items-center justify-center">
@unless ($isPreviewable)
<flux:tooltip content="Buy the course to unlock all lessons">
<button
type="button"
class="cursor-help text-zinc-400 dark:text-zinc-500"
aria-label="Buy the course to unlock all lessons"
>
<x-heroicon-m-lock-closed class="size-4" />
</button>
</flux:tooltip>
@endunless
</div>
<div class="min-w-0 flex-1">
@if ($isPreviewable)
<a href="{{ route('customer.course.lesson', $lesson) }}" wire:navigate class="text-sm font-medium transition-colors hover:text-emerald-600 dark:hover:text-emerald-400">
{{ $lesson->title }}
</a>
@else
<span class="text-sm font-medium text-zinc-400 dark:text-zinc-500">{{ $lesson->title }}</span>
@endif
</div>
@if ($lesson->duration_in_seconds)
<flux:text class="shrink-0 text-xs">{{ gmdate('i:s', $lesson->duration_in_seconds) }}</flux:text>
@endif
</div>
@endforeach
</div>
</div>
@endforeach
</div>
</div>
@endif

</div>
@endif
</div>
15 changes: 8 additions & 7 deletions resources/views/livewire/customer/course/lesson-show.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 --}}
<a
href="{{ route('customer.course.lesson', $outlineLesson) }}"
wire:navigate
@unless ($isLocked) wire:navigate @endunless
wire:key="outline-lesson-{{ $outlineLesson->id }}"
@if ($isCurrent) aria-current="page" @endif
class="flex items-center gap-3 px-4 py-2 text-sm transition-colors {{ $isCurrent ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300' : 'text-zinc-600 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-white/5' }}"
class="flex items-center gap-3 px-4 py-2 text-sm transition-colors {{ $isCurrent ? 'bg-emerald-50 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-300' : ($isLocked ? 'text-zinc-400 hover:bg-zinc-50 dark:text-zinc-600 dark:hover:bg-white/5' : 'text-zinc-600 hover:bg-zinc-50 dark:text-zinc-400 dark:hover:bg-white/5') }}"
>
@if ($isLocked)
<x-heroicon-m-lock-closed class="size-4 shrink-0" />
@endif
<span class="min-w-0 flex-1 truncate {{ $isCurrent ? 'font-medium' : '' }} {{ $isLessonComplete ? 'line-through' : '' }}" title="{{ $outlineLesson->title }}">{{ $outlineLesson->title }}</span>
@if ($isDraft)
<flux:badge variant="pill" color="amber" size="sm">Coming Soon</flux:badge>
Expand All @@ -119,9 +123,6 @@ class="flex items-center gap-3 px-4 py-2 text-sm transition-colors {{ $isCurrent
</a>
@else
<div wire:key="outline-lesson-{{ $outlineLesson->id }}" class="flex items-center gap-3 px-4 py-2 text-sm text-zinc-400 dark:text-zinc-600">
@if ($isLocked)
<x-heroicon-m-lock-closed class="size-4 shrink-0" />
@endif
<span class="min-w-0 flex-1 truncate {{ $isLessonComplete ? 'line-through' : '' }}" title="{{ $outlineLesson->title }}">{{ $outlineLesson->title }}</span>
@if ($isDraft)
<flux:badge variant="pill" color="amber" size="sm">Coming Soon</flux:badge>
Expand Down
Loading
Loading