Separate webinars from meetings and add conference billing.
Deploy Ladill Meet / deploy (push) Successful in 41s
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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.*'),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'), '/');
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user