Separate webinars from meetings and add conference billing.
Deploy Ladill Meet / deploy (push) Successful in 41s

Add restart meeting actions, a dedicated webinar sidebar flow billed at GHS 0.30 per participant, and reserve speaker-line space with a placeholder to prevent layout shift.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 18:23:04 +00:00
co-authored by Cursor
parent 3585904618
commit d0a3361f37
24 changed files with 598 additions and 51 deletions
@@ -79,8 +79,11 @@ class MeetingRoomController extends Controller
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
return redirect()->route('meet.rooms.show', $session->room)
->with('success', 'Meeting ended.');
$room = $session->room;
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show';
return redirect()->route($route, $room)
->with('success', $room->isWebinar() ? 'Webinar ended.' : 'Meeting ended.');
}
public function leave(Request $request, Session $session): RedirectResponse
+8 -9
View File
@@ -44,7 +44,8 @@ class RoomController extends Controller
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)->where('organization_id', $organization->id);
$query = Room::owned($owner)->where('organization_id', $organization->id)
->where('type', '!=', 'webinar');
$this->scopeToBranch($request, $query);
$rooms = $query->orderByDesc('scheduled_at')->paginate(20);
@@ -79,7 +80,7 @@ class RoomController extends Controller
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'type' => ['nullable', 'string', 'in:scheduled,webinar,town_hall'],
'type' => ['nullable', 'string', 'in:scheduled,town_hall'],
'scheduled_at' => ['nullable', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
@@ -104,7 +105,7 @@ class RoomController extends Controller
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'webinar_auto_approve' => $request->boolean('webinar_auto_approve'),
'stage_mode' => ($validated['type'] ?? '') === 'webinar',
'stage_mode' => false,
];
$data = array_merge($validated, [
@@ -121,12 +122,6 @@ class RoomController extends Controller
if (! empty($validated['recurrence_rule']) && ! $isInstant) {
$room = $this->recurring->createSeries($request->user(), $organization, $data);
} elseif (($validated['type'] ?? '') === 'webinar') {
abort_if($isInstant, 422, 'Webinars must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
'type' => 'webinar',
'status' => 'scheduled',
]));
} elseif (($validated['type'] ?? '') === 'town_hall') {
abort_if($isInstant, 422, 'Town halls must be scheduled.');
$room = $this->rooms->create($request->user(), $organization, array_merge($data, [
@@ -163,6 +158,10 @@ class RoomController extends Controller
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
if ($room->isWebinar()) {
return redirect()->route('meet.webinars.show', $room);
}
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
return view('meet.rooms.show', compact('room'));
@@ -0,0 +1,157 @@
<?php
namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\RoomService;
use App\Services\Meet\SessionService;
use App\Services\Meet\TemplateService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class WebinarController extends Controller
{
use ScopesToAccount;
public function __construct(
protected RoomService $rooms,
protected SessionService $sessions,
protected TemplateService $templates,
protected InvitationService $invitations,
protected CalendarService $calendar,
) {}
public function index(Request $request): View
{
$this->authorizeAbility($request, 'meetings.view');
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$query = Room::owned($owner)
->where('organization_id', $organization->id)
->where('type', 'webinar');
$this->scopeToBranch($request, $query);
$webinars = $query->orderByDesc('scheduled_at')->paginate(20);
return view('meet.webinars.index', compact('webinars', 'organization'));
}
public function create(Request $request): View
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$templates = Template::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->orderBy('name')
->get();
return view('meet.webinars.create', [
'organization' => $organization,
'templates' => $templates,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
}
public function store(Request $request): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.create');
$organization = $this->organization($request);
$validated = $request->validate([
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string', 'max:2000'],
'scheduled_at' => ['required', 'date'],
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
'timezone' => ['nullable', 'timezone'],
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
'auto_record' => ['sometimes', 'boolean'],
'live_captions' => ['sometimes', 'boolean'],
'webinar_auto_approve' => ['sometimes', 'boolean'],
]);
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
'mute_on_join' => $request->boolean('mute_on_join', true),
'auto_record' => $request->boolean('auto_record'),
'live_captions' => $request->boolean('live_captions'),
'webinar_auto_approve' => $request->boolean('webinar_auto_approve'),
'stage_mode' => true,
];
$data = array_merge($validated, [
'settings' => $settings,
'timezone' => $validated['timezone'] ?? $organization->timezone,
'type' => 'webinar',
'status' => 'scheduled',
]);
if (! empty($validated['template_id'])) {
$template = Template::findOrFail($validated['template_id']);
$data = $this->templates->applyToRoomData($template, $data);
}
$room = $this->rooms->create($request->user(), $organization, $data);
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
if ($room->scheduled_at) {
$this->calendar->syncRoomCreated($room, $request->user());
}
return redirect()->route('meet.webinars.show', $room)->with('success', 'Webinar created.');
}
public function show(Request $request, Room $room): View
{
$this->authorizeAbility($request, 'meetings.view');
$this->authorizeOwner($request, $room);
abort_unless($room->isWebinar(), 404);
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
return view('meet.webinars.show', compact('room'));
}
public function start(Request $request, Room $room): RedirectResponse
{
$this->authorizeAbility($request, 'meetings.host');
$this->authorizeOwner($request, $room);
abort_unless($room->isWebinar(), 404);
if ($room->status === 'cancelled') {
return redirect()->route('meet.webinars.show', $room)
->withErrors(['start' => 'Webinar was cancelled.']);
}
$session = $this->sessions->start($room, $request->user());
$participant = $this->sessions->joinedParticipantForUser($session, $request->user());
abort_unless($participant, 500, 'Unable to join as host.');
$this->sessions->rememberParticipantSession($request, $participant, $session);
return redirect()->route('meet.room', $session);
}
}
+22
View File
@@ -81,6 +81,28 @@ class Room extends Model
return $this->type === 'webinar';
}
public function canRestart(): bool
{
if ($this->status === 'cancelled' || $this->activeSession()) {
return false;
}
return $this->status === 'ended'
|| $this->type === 'personal'
|| $this->status === 'scheduled';
}
public function restartLabel(): string
{
$restarting = $this->status === 'ended' || $this->sessions()->exists();
if ($this->isWebinar()) {
return $restarting ? 'Restart webinar' : 'Start webinar';
}
return $restarting ? 'Restart meeting' : 'Start meeting';
}
public function isTownHall(): bool
{
return $this->type === 'town_hall';
+3 -2
View File
@@ -3,6 +3,7 @@
namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Room;
use App\Models\UsageRecord;
class LicenseService
@@ -59,8 +60,8 @@ class LicenseService
return ($used + $additional) <= $limit;
}
public function assertCanStartSession(Organization $organization, ?int $branchId = null): void
public function assertCanStartSession(Organization $organization, ?int $branchId = null, ?Room $room = null): void
{
app(MeetBillingService::class)->assertCanStartSession($organization);
app(MeetBillingService::class)->assertCanStartSession($organization, $room);
}
}
+79 -12
View File
@@ -4,6 +4,7 @@ namespace App\Services\Meet;
use App\Models\Organization;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Models\SessionFile;
use App\Models\UsageRecord;
@@ -27,6 +28,16 @@ class MeetBillingService
return (float) config('meet.billing.price_per_hour_ghs', 0.30);
}
public function pricePerParticipantGhs(): float
{
return (float) config('meet.billing.price_per_participant_ghs', 0.30);
}
public function usesParticipantBilling(?Room $room): bool
{
return $room?->isWebinar() ?? false;
}
public function pricePerGbMonthGhs(): float
{
return (float) config('meet.billing.price_per_gb_month_ghs', 0.30);
@@ -66,6 +77,18 @@ class MeetBillingService
return max((int) round($ghs * 100), 1);
}
public function participantsBillableFor(Session $session): int
{
return max(1, (int) ($session->peak_participants ?? 0));
}
public function amountMinorForParticipants(int $count): int
{
$ghs = round($count * $this->pricePerParticipantGhs(), 2);
return max((int) round($ghs * 100), 1);
}
public function amountMinorForStorageBytes(int $bytes): int
{
if ($bytes <= 0) {
@@ -77,11 +100,23 @@ class MeetBillingService
return max((int) round($ghs * 100), 1);
}
public function assertCanStartSession(Organization $organization): void
public function assertCanStartSession(Organization $organization, ?Room $room = null): void
{
$amountMinor = $this->amountMinorForStreamingHours(
max(1, (int) config('meet.billing.minimum_streaming_hours', 1))
);
if ($this->usesParticipantBilling($room)) {
$amountMinor = $this->amountMinorForParticipants(1);
$message = sprintf(
'Insufficient wallet balance. Webinars are billed at GHS %.2f per participant — top up your Ladill wallet to start.',
$this->pricePerParticipantGhs(),
);
} else {
$amountMinor = $this->amountMinorForStreamingHours(
max(1, (int) config('meet.billing.minimum_streaming_hours', 1))
);
$message = sprintf(
'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.',
$this->pricePerHourGhs(),
);
}
$affordable = $this->canAffordSafely($organization->owner_ref, $amountMinor);
@@ -89,14 +124,20 @@ class MeetBillingService
abort(503, 'Billing is temporarily unavailable. Please try again shortly.');
}
abort_unless(
$affordable,
402,
sprintf(
'Insufficient wallet balance. Meetings are billed at GHS %.2f per hour — top up your Ladill wallet to start.',
$this->pricePerHourGhs(),
),
);
abort_unless($affordable, 402, $message);
}
public function chargeSession(Session $session): void
{
$session->loadMissing('room');
if ($this->usesParticipantBilling($session->room)) {
$this->chargeParticipants($session);
return;
}
$this->chargeStreaming($session);
}
public function chargeStreaming(Session $session): void
@@ -125,6 +166,32 @@ class MeetBillingService
);
}
public function chargeParticipants(Session $session): void
{
$session->loadMissing('room.organization');
$organization = $session->room->organization;
$count = $this->participantsBillableFor($session);
$amountMinor = $this->amountMinorForParticipants($count);
if ($amountMinor <= 0) {
return;
}
$this->recordUsage($organization, 'participant_count', $count, [
'session_uuid' => $session->uuid,
'room_uuid' => $session->room->uuid,
'participants' => $count,
], $session->room->branch_id);
$this->debit(
$organization->owner_ref,
$amountMinor,
'meet_participants',
'meet-participants-'.$session->uuid,
sprintf('Meet webinar: %s (%d participants)', $session->room->title, $count),
);
}
public function chargeRecordingStorageInitial(Recording $recording): void
{
$recording->loadMissing('session.room.organization');
+1 -1
View File
@@ -24,7 +24,7 @@ class SessionService
return $existing;
}
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id);
app(LicenseService::class)->assertCanStartSession($room->organization, $room->branch_id, $room);
$session = Session::create([
'owner_ref' => $room->owner_ref,
+5 -1
View File
@@ -14,7 +14,7 @@ class UsageMeteringService
public function recordSessionUsage(Session $session): void
{
$this->billing->chargeStreaming($session);
$this->billing->chargeSession($session);
}
public function recordAiMinutes(Organization $organization, int $minutes, string $sessionUuid): void
@@ -49,6 +49,8 @@ class UsageMeteringService
foreach ($raw as $metric => $total) {
if ($metric === 'streaming_hours') {
$summary['streaming_hours'] = round(((int) $total) / 100, 2);
} elseif ($metric === 'participant_count') {
$summary['participant_count'] = (int) $total;
} else {
$summary[$metric] = (int) $total;
}
@@ -60,11 +62,13 @@ class UsageMeteringService
public function estimatedCostGhs(array $summary): array
{
$streamingHours = (float) ($summary['streaming_hours'] ?? 0);
$participantCount = (int) ($summary['participant_count'] ?? 0);
$recordingBytes = (int) ($summary['recording_storage_bytes'] ?? 0);
$fileBytes = (int) ($summary['file_storage_bytes'] ?? 0);
return [
'streaming' => round($streamingHours * $this->billing->pricePerHourGhs(), 2),
'participants' => round($participantCount * $this->billing->pricePerParticipantGhs(), 2),
'recording_storage' => round($this->billing->storageGb($recordingBytes) * $this->billing->pricePerGbMonthGhs(), 2),
'file_storage' => round($this->billing->storageGb($fileBytes) * $this->billing->pricePerGbMonthGhs(), 2),
];
+1
View File
@@ -173,6 +173,7 @@ return [
'billing' => [
'price_per_hour_ghs' => (float) env('MEET_PRICE_PER_HOUR', 0.30),
'price_per_participant_ghs' => (float) env('MEET_PRICE_PER_PARTICIPANT', 0.30),
'price_per_gb_month_ghs' => (float) env('MEET_PRICE_PER_GB_MONTH', 0.30),
'billing_period_days' => (int) env('MEET_BILLING_PERIOD_DAYS', 30),
'grace_period_days' => (int) env('MEET_GRACE_PERIOD_DAYS', 15),
@@ -7,6 +7,7 @@
<h2 class="font-medium text-slate-900">Pay-as-you-go rates</h2>
<ul class="mt-3 space-y-2 text-sm text-slate-600">
<li>Live meetings: <span class="font-medium text-slate-900">GHS {{ number_format($billing->pricePerHourGhs(), 2) }} / hour</span> (billed when a session ends)</li>
<li>Webinars & conferences: <span class="font-medium text-slate-900">GHS {{ number_format($billing->pricePerParticipantGhs(), 2) }} / participant</span> (peak attendance when the session ends)</li>
<li>Recordings & shared files: <span class="font-medium text-slate-900">GHS {{ number_format($billing->pricePerGbMonthGhs(), 2) }} / GB / month</span> (same as Ladill Transfer)</li>
</ul>
<p class="mt-3 text-xs text-slate-500">Charges debit your Ladill wallet. Storage renews monthly; unpaid items enter a {{ $billing->gracePeriodDays() }}-day grace period before deletion.</p>
@@ -16,6 +17,7 @@
@php
$rows = [
'streaming_hours' => ['label' => 'Streaming hours', 'used' => number_format($summary['streaming_hours'] ?? 0, 1), 'cost' => $costs['streaming'] ?? 0],
'participant_count' => ['label' => 'Webinar participants', 'used' => number_format($summary['participant_count'] ?? 0), 'cost' => $costs['participants'] ?? 0],
'recording_storage_bytes' => ['label' => 'Recording storage', 'used' => number_format(($summary['recording_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['recording_storage'] ?? 0],
'file_storage_bytes' => ['label' => 'Shared file storage', 'used' => number_format(($summary['file_storage_bytes'] ?? 0) / 1073741824, 2).' GB', 'cost' => $costs['file_storage'] ?? 0],
'ai_minutes' => ['label' => 'AI minutes', 'used' => number_format($summary['ai_minutes'] ?? 0), 'cost' => null],
@@ -4,7 +4,7 @@
<h1 class="text-xl font-semibold text-slate-900">Invitations</h1>
<p class="mt-1 text-sm text-slate-500">{{ $room->title }}</p>
</div>
<a href="{{ route('meet.rooms.show', $room) }}" class="text-sm text-indigo-600 hover:underline"> Back to meeting</a>
<a href="{{ route($room->isWebinar() ? 'meet.webinars.show' : 'meet.rooms.show', $room) }}" class="text-sm text-indigo-600 hover:underline"> Back to {{ $room->isWebinar() ? 'webinar' : 'meeting' }}</a>
</div>
<div class="mt-6 grid gap-6 lg:grid-cols-2">
@@ -5,11 +5,16 @@
'ended' => 'bg-slate-100 text-slate-600',
'cancelled' => 'bg-red-50 text-red-700',
];
$detailsRoute = $detailsRoute ?? route('meet.rooms.show', $room);
$startRoute = $startRoute ?? route('meet.rooms.start', $room);
@endphp
<div class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white p-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-medium text-slate-900">{{ $room->title }}</h3>
@if ($room->isWebinar())
<span class="shrink-0 rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-medium text-indigo-700">Webinar</span>
@endif
<span class="shrink-0 rounded-full px-2 py-0.5 text-xs font-medium {{ $statusColors[$room->status] ?? 'bg-slate-100 text-slate-600' }}">
{{ config('meet.room_statuses')[$room->status] ?? $room->status }}
</span>
@@ -27,12 +32,12 @@
@if ($room->activeSession())
<a href="{{ route('meet.room', $room->activeSession()) }}" class="btn-primary text-sm">Join</a>
@endif
@elseif ($room->status === 'scheduled')
<form method="POST" action="{{ route('meet.rooms.start', $room) }}">
@elseif ($room->canRestart())
<form method="POST" action="{{ $startRoute }}">
@csrf
<button type="submit" class="btn-primary text-sm">Start</button>
<button type="submit" class="btn-primary text-sm">{{ $room->restartLabel() }}</button>
</form>
@endif
<a href="{{ route('meet.rooms.show', $room) }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Details</a>
<a href="{{ $detailsRoute }}" class="rounded-lg border border-slate-300 px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Details</a>
</div>
</div>
+3 -3
View File
@@ -81,10 +81,10 @@
<div class="min-w-0">
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
<p x-show="activeSpeakersMessage()" x-cloak
class="truncate text-xs font-medium text-emerald-400"
<p class="min-h-4 truncate text-xs font-medium leading-4"
:class="activeSpeakerCount() > 0 ? 'text-emerald-400' : 'text-slate-600'"
:title="activeSpeakersMessage()"
x-text="activeSpeakersMessage()"></p>
x-text="activeSpeakersMessage() || '...'"></p>
</div>
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
<button @click.stop="openShare()" data-share-trigger type="button"
@@ -17,14 +17,6 @@
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Meeting type</label>
<select name="type" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="scheduled">Scheduled meeting</option>
<option value="webinar" @selected(old('type') === 'webinar')>Webinar</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Title</label>
<input type="text" name="title" value="{{ old('title') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@@ -98,7 +90,6 @@
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', $defaultSettings['mute_on_join'] ?? true)) class="rounded border-slate-300"> Mute participants on join</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" @checked(old('auto_record')) class="rounded border-slate-300"> Auto-record</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="live_captions" value="1" @checked(old('live_captions')) class="rounded border-slate-300"> Live captions</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="webinar_auto_approve" value="1" @checked(old('webinar_auto_approve')) class="rounded border-slate-300"> Auto-approve webinar registrations</label>
</fieldset>
<button type="submit" class="btn-primary btn-primary-lg w-full">Create meeting</button>
+11 -4
View File
@@ -5,7 +5,12 @@
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
</div>
@if ($room->status === 'scheduled')
@if ($room->canRestart())
<form method="POST" action="{{ route('meet.rooms.start', $room) }}">
@csrf
<button type="submit" class="btn-primary">{{ $room->restartLabel() }}</button>
</form>
@elseif ($room->status === 'scheduled')
<form method="POST" action="{{ route('meet.rooms.start', $room) }}">
@csrf
<button type="submit" class="btn-primary">Start meeting</button>
@@ -33,9 +38,11 @@
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
@endif
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
@if ($room->isWebinar())
<a href="{{ route('meet.webinar.registrations', $room) }}" class="btn-secondary btn-secondary-sm">Registrations</a>
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">Registration page</a>
@if ($room->canRestart() && ($room->status === 'ended' || $room->type === 'personal' || $room->sessions->isNotEmpty()))
<form method="POST" action="{{ route('meet.rooms.start', $room) }}" class="inline">
@csrf
<button type="submit" class="btn-secondary btn-secondary-sm">{{ $room->restartLabel() }}</button>
</form>
@endif
</div>
@@ -4,6 +4,7 @@
<div>
<h1 class="text-xl font-semibold text-slate-900">Registrations</h1>
<p class="text-sm text-slate-500">{{ $room->title }}</p>
<a href="{{ route('meet.webinars.show', $room) }}" class="mt-1 inline-block text-sm text-indigo-600 hover:underline"> Back to webinar</a>
</div>
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="text-sm text-indigo-600 hover:underline">Public registration page</a>
</div>
@@ -0,0 +1,80 @@
<x-app-layout title="Schedule webinar">
<div class="mx-auto max-w-xl">
<h1 class="text-xl font-semibold text-slate-900">Schedule a webinar</h1>
<p class="mt-1 text-sm text-slate-500">Attendees register in advance. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.</p>
<form method="POST" action="{{ route('meet.webinars.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
@if ($templates->isNotEmpty())
<div>
<label class="block text-sm font-medium text-slate-700">Template</label>
<select name="template_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
<option value="">None</option>
@foreach ($templates as $template)
<option value="{{ $template->id }}" @selected(old('template_id') == $template->id)>{{ $template->name }}</option>
@endforeach
</select>
</div>
@endif
<div>
<label class="block text-sm font-medium text-slate-700">Title</label>
<input type="text" name="title" value="{{ old('title') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Description</label>
<textarea name="description" rows="3" class="mt-1 w-full rounded-lg border-slate-300 text-sm">{{ old('description') }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Date & time</label>
<input type="datetime-local" name="scheduled_at" value="{{ old('scheduled_at') }}" required class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-slate-700">Duration (minutes)</label>
<input type="number" name="duration_minutes" value="{{ old('duration_minutes', 60) }}" min="15" max="480" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Timezone</label>
<select name="timezone" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach (['UTC', 'America/New_York', 'America/Chicago', 'America/Los_Angeles', 'Europe/London', 'Africa/Lagos'] as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Invite panelists by email (comma-separated)</label>
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="panelist@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Passcode (optional)</label>
<input type="text" name="passcode" value="{{ old('passcode') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-medium text-slate-700">Webinar options</legend>
<label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', $defaultSettings['waiting_room'] ?? true)) class="mt-0.5 rounded border-slate-300">
<span>
<span class="font-medium text-slate-800">Waiting room</span>
<span class="mt-0.5 block text-xs text-slate-500">Hold registrants until you admit them.</span>
</span>
</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="join_before_host" value="1" @checked(old('join_before_host')) class="rounded border-slate-300"> Allow join before host</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', $defaultSettings['mute_on_join'] ?? true)) class="rounded border-slate-300"> Mute attendees on join</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" @checked(old('auto_record')) class="rounded border-slate-300"> Auto-record</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="live_captions" value="1" @checked(old('live_captions')) class="rounded border-slate-300"> Live captions</label>
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="webinar_auto_approve" value="1" @checked(old('webinar_auto_approve')) class="rounded border-slate-300"> Auto-approve registrations</label>
</fieldset>
<button type="submit" class="btn-primary btn-primary-lg w-full">Create webinar</button>
</form>
</div>
</x-app-layout>
@@ -0,0 +1,24 @@
<x-app-layout title="Webinars">
<div class="flex items-center justify-between">
<div>
<h1 class="text-xl font-semibold text-slate-900">Webinars</h1>
<p class="mt-1 text-sm text-slate-500">Stage-mode events billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant.</p>
</div>
<a href="{{ route('meet.webinars.create') }}" class="btn-primary">Schedule webinar</a>
</div>
<div class="mt-4 space-y-2">
@forelse ($webinars as $room)
@include('meet.partials.room-card', [
'room' => $room,
'live' => $room->status === 'live',
'detailsRoute' => route('meet.webinars.show', $room),
'startRoute' => route('meet.webinars.start', $room),
])
@empty
<p class="rounded-2xl border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">No webinars yet.</p>
@endforelse
</div>
<div class="mt-4">{{ $webinars->links() }}</div>
</x-app-layout>
@@ -0,0 +1,121 @@
<x-app-layout title="{{ $room->title }}">
<div class="mx-auto max-w-2xl">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Webinar</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
</div>
@if ($room->canRestart())
<form method="POST" action="{{ route('meet.webinars.start', $room) }}">
@csrf
<button type="submit" class="btn-primary">{{ $room->restartLabel() }}</button>
</form>
@endif
</div>
@if ($errors->any())
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ $errors->first() }}
</div>
@endif
<div class="mt-4 rounded-xl border border-indigo-100 bg-indigo-50 px-4 py-3 text-sm text-indigo-900">
Conference billing: <span class="font-medium">GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant</span> (peak attendance when the session ends).
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Registration link</h2>
<div class="mt-2 flex gap-2">
<input type="text" readonly value="{{ route('meet.webinar.register', $room) }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
<x-copy-button :text="route('meet.webinar.register', $room)" class="btn-secondary btn-secondary-sm shrink-0" />
</div>
<h2 class="mt-6 text-sm font-medium text-slate-700">Join link (hosts & panelists)</h2>
<div class="mt-2 flex gap-2">
<input type="text" readonly value="{{ $room->joinUrl() }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
<x-copy-button :text="$room->joinUrl()" class="btn-secondary btn-secondary-sm shrink-0" />
</div>
<div class="btn-group mt-4">
<a href="{{ route('meet.rooms.ical', $room) }}" class="btn-secondary btn-secondary-sm">Download iCal</a>
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
@if ($room->sessions->isNotEmpty())
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
@endif
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
@if ($room->canRestart() && ($room->status === 'ended' || $room->sessions->isNotEmpty()))
<form method="POST" action="{{ route('meet.webinars.start', $room) }}" class="inline">
@csrf
<button type="submit" class="btn-secondary btn-secondary-sm">{{ $room->restartLabel() }}</button>
</form>
@endif
<a href="{{ route('meet.webinar.registrations', $room) }}" class="btn-secondary btn-secondary-sm">Registrations</a>
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">Registration page</a>
</div>
@if ($room->passcode)
<p class="mt-3 text-sm text-slate-600">Passcode: <span class="font-mono font-medium">{{ $room->passcode }}</span></p>
@endif
@if ($room->scheduled_at)
<p class="mt-3 text-sm text-slate-600">
Scheduled: {{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}
</p>
@endif
</div>
@php
$recordings = $room->sessions->flatMap->recordings->sortByDesc('created_at');
$summaries = $room->sessions->flatMap->aiSummaries->where('status', 'ready')->sortByDesc('created_at');
@endphp
@if ($recordings->isNotEmpty())
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Recordings</h2>
<ul class="mt-3 divide-y divide-slate-100 text-sm">
@foreach ($recordings as $recording)
<li class="flex items-center justify-between py-2">
<span>{{ $recording->created_at->format('M j, Y g:i A') }}</span>
<a href="{{ route('meet.recordings.show', $recording) }}" class="text-indigo-600 hover:underline">{{ config('meet.recording_statuses')[$recording->status] ?? $recording->status }}</a>
</li>
@endforeach
</ul>
</div>
@endif
@if ($summaries->isNotEmpty())
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">AI summaries</h2>
<ul class="mt-3 divide-y divide-slate-100 text-sm">
@foreach ($summaries as $summary)
<li class="py-2">
<a href="{{ route('meet.summaries.show', $summary) }}" class="text-indigo-600 hover:underline">Summary · {{ $summary->created_at->format('M j, Y') }}</a>
</li>
@endforeach
</ul>
</div>
@endif
@if ($room->sessions->isNotEmpty())
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Sessions</h2>
<ul class="mt-3 divide-y divide-slate-100 text-sm">
@foreach ($room->sessions as $session)
<li class="flex items-center justify-between py-2">
<span>{{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }}</span>
<span class="text-slate-500">{{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }}</span>
</li>
@endforeach
</ul>
</div>
@endif
@if ($room->status === 'scheduled')
<form method="POST" action="{{ route('meet.rooms.cancel', $room) }}" class="mt-6" onsubmit="return confirm('Cancel this webinar?')">
@csrf
<button type="submit" class="text-sm text-red-600 hover:underline">Cancel webinar</button>
</form>
@endif
</div>
</x-app-layout>
+3 -1
View File
@@ -13,8 +13,10 @@
$nav = [
['name' => 'Dashboard', 'route' => route('meet.dashboard'), 'active' => request()->routeIs('meet.dashboard'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*'),
['name' => 'Meetings', 'route' => route('meet.rooms.index'), 'active' => request()->routeIs('meet.rooms.*') && ! request()->routeIs('meet.webinar.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />'],
['name' => 'Webinars', 'route' => route('meet.webinars.index'), 'active' => request()->routeIs('meet.webinars.*') || request()->routeIs('meet.webinar.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z" />'],
['name' => 'Recordings', 'route' => route('meet.recordings.index'), 'active' => request()->routeIs('meet.recordings.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />'],
['name' => 'Reports', 'route' => route('meet.reports.index'), 'active' => request()->routeIs('meet.reports.*'),
+8
View File
@@ -22,6 +22,7 @@ use App\Http\Controllers\Meet\SettingsController;
use App\Http\Controllers\Meet\SummaryController;
use App\Http\Controllers\Meet\TemplateController;
use App\Http\Controllers\Meet\WebinarRegistrationController;
use App\Http\Controllers\Meet\WebinarController;
use App\Http\Controllers\Meet\WebhookSettingsController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\WalletBalanceController;
@@ -123,6 +124,13 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/meetings/{room}/ical', [RoomController::class, 'ical'])->name('meet.rooms.ical');
Route::get('/meetings/{room}/qr', [RoomController::class, 'qr'])->name('meet.rooms.qr');
Route::get('/meetings/{room}/attendance', [RoomController::class, 'attendance'])->name('meet.rooms.attendance');
Route::get('/webinars', [WebinarController::class, 'index'])->name('meet.webinars.index');
Route::get('/webinars/create', [WebinarController::class, 'create'])->name('meet.webinars.create');
Route::post('/webinars', [WebinarController::class, 'store'])->name('meet.webinars.store');
Route::get('/webinars/{room}', [WebinarController::class, 'show'])->name('meet.webinars.show');
Route::post('/webinars/{room}/start', [WebinarController::class, 'start'])->name('meet.webinars.start');
Route::get('/meetings/{room}/webinar/registrations', [WebinarRegistrationController::class, 'manage'])->name('meet.webinar.registrations');
Route::post('/meetings/{room}/webinar/registrations/{registration}/approve', [WebinarRegistrationController::class, 'approve'])->name('meet.webinar.registrations.approve');
Route::post('/meetings/{room}/webinar/registrations/{registration}/reject', [WebinarRegistrationController::class, 'reject'])->name('meet.webinar.registrations.reject');
+43
View File
@@ -66,6 +66,49 @@ class MeetBillingTest extends TestCase
&& ($request->data()['source'] ?? '') === 'meet_streaming');
}
public function test_webinar_session_end_charges_per_participant(): void
{
$this->fakeAffordableBilling();
$organization = Organization::create([
'owner_ref' => 'owner-webinar',
'name' => 'Webinar Org',
'slug' => 'webinar-org',
'timezone' => 'UTC',
]);
$room = Room::create([
'owner_ref' => 'owner-webinar',
'organization_id' => $organization->id,
'host_user_ref' => 'owner-webinar',
'title' => 'Webinar Room',
'type' => 'webinar',
'status' => 'ended',
'settings' => config('meet.default_settings'),
]);
$session = Session::create([
'owner_ref' => 'owner-webinar',
'room_id' => $room->id,
'media_room_name' => 'webinar-test',
'status' => 'ended',
'started_at' => now()->subHour(),
'ended_at' => now(),
'peak_participants' => 5,
]);
app(UsageMeteringService::class)->recordSessionUsage($session);
$this->assertDatabaseHas('meet_usage_records', [
'organization_id' => $organization->id,
'metric' => 'participant_count',
'quantity' => 5,
]);
Http::assertSent(fn ($request) => str_contains($request->url(), '/debit')
&& ($request->data()['source'] ?? '') === 'meet_participants');
}
public function test_storage_renewal_enters_grace_when_wallet_is_empty(): void
{
$base = rtrim((string) config('billing.api_url'), '/');
+1 -2
View File
@@ -541,9 +541,8 @@ class MeetWebTest extends TestCase
public function test_host_can_create_webinar_room(): void
{
$this->actingAs($this->user)
->post('/meetings', [
->post('/webinars', [
'title' => 'Product launch',
'type' => 'webinar',
'scheduled_at' => now()->addDay()->format('Y-m-d\TH:i'),
'duration_minutes' => 60,
])
+10
View File
@@ -30,6 +30,16 @@ class MeetBillingServiceTest extends TestCase
$this->assertSame(60, $billing->amountMinorForStreamingHours(2));
}
public function test_participant_amount_minor_matches_rate(): void
{
config(['meet.billing.price_per_participant_ghs' => 0.30]);
$billing = app(MeetBillingService::class);
$this->assertSame(30, $billing->amountMinorForParticipants(1));
$this->assertSame(90, $billing->amountMinorForParticipants(3));
}
public function test_storage_amount_minor_matches_gb_monthly_rate(): void
{
config(['meet.billing.price_per_gb_month_ghs' => 0.30]);