Improve conferences, guest entry, Afia, and cross-app scheduling.
Deploy Ladill Meet / deploy (push) Successful in 50s

Route guests through silent SSO to the Meet product page, fix Afia context
query, add conference green-room UX and copy, and extend service API for
Care/CRM/Invoice calendar integration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-01 20:12:57 +00:00
co-authored by Cursor
parent be8f76cd47
commit 799c302e2a
32 changed files with 601 additions and 89 deletions
+5 -1
View File
@@ -6,6 +6,7 @@ APP_URL=https://meet.ladill.com
PLATFORM_URL=https://ladill.com
PLATFORM_DOMAIN=ladill.com
LADILL_MARKETING_URL=https://ladill.com/products/meet
LOG_CHANNEL=stack
LOG_LEVEL=debug
@@ -59,8 +60,11 @@ MEET_API_KEY_EVENTS=
MEET_API_KEY_FRONTDESK=
MEET_API_KEY_HR=
MEET_API_KEY_SUPPORT=
MEET_API_KEY_INVOICE=
# --- Calendar OAuth (optional) ---
# --- Ladill Mail calendar (auto-sync scheduled meetings) ---
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
MAIL_API_KEY_MEET=
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REDIRECT_URI=https://meet.ladill.com/settings/calendar/callback
@@ -28,7 +28,7 @@ class ServiceRoomController extends Controller
{
$validated = $request->validate([
'owner_ref' => ['required', 'string'],
'organization_id' => ['required', 'integer', 'exists:meet_organizations,id'],
'organization_id' => ['nullable', 'integer', 'exists:meet_organizations,id'],
'host_user_ref' => ['required', 'string'],
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
@@ -44,7 +44,9 @@ class ServiceRoomController extends Controller
'invite_emails.*' => ['email'],
]);
$organization = Organization::findOrFail($validated['organization_id']);
$organization = isset($validated['organization_id'])
? Organization::findOrFail($validated['organization_id'])
: Organization::owned($validated['owner_ref'])->firstOrFail();
abort_unless($organization->owner_ref === $validated['owner_ref'], 403);
$host = User::where('public_id', $validated['host_user_ref'])->firstOrFail();
@@ -88,11 +88,7 @@ class SsoLoginController extends Controller
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]);
return redirect()->away((string) config('ladill.marketing_url'));
}
return $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')));
+1 -1
View File
@@ -52,7 +52,7 @@ class AiController extends Controller
'meetings_upcoming' => Room::owned($owner)
->where('organization_id', $organization->id)
->where('status', 'scheduled')
->where('starts_at', '>=', now())
->where('scheduled_at', '>=', now())
->count(),
'recordings_total' => Recording::owned($owner)
->whereHas('session.room', fn ($q) => $q->where('organization_id', $organization->id))
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
use App\Models\Member;
use App\Models\Room;
use App\Models\Template;
use App\Services\Meet\CalendarService;
@@ -56,9 +57,16 @@ class ConferenceController extends Controller
->orderBy('name')
->get();
$members = Member::owned($this->ownerRef($request))
->where('organization_id', $organization->id)
->where('user_ref', '!=', $this->ownerRef($request))
->orderBy('user_ref')
->get();
return view('meet.conferences.create', [
'organization' => $organization,
'templates' => $templates,
'members' => $members,
'timezones' => timezone_identifiers_list(),
'defaultSettings' => config('meet.default_settings'),
]);
@@ -78,6 +86,8 @@ class ConferenceController extends Controller
'passcode' => ['nullable', 'string', 'max:20'],
'template_id' => ['nullable', 'integer', 'exists:meet_templates,id'],
'invite_emails' => ['nullable', 'string'],
'presenter_refs' => ['nullable', 'array'],
'presenter_refs.*' => ['string', 'max:64'],
'waiting_room' => ['sometimes', 'boolean'],
'join_before_host' => ['sometimes', 'boolean'],
'mute_on_join' => ['sometimes', 'boolean'],
@@ -87,6 +97,12 @@ class ConferenceController extends Controller
'practice_mode' => ['sometimes', 'boolean'],
]);
$presenterRefs = collect($validated['presenter_refs'] ?? [])
->filter()
->unique()
->values()
->all();
$settings = [
'waiting_room' => $request->boolean('waiting_room', true),
'join_before_host' => $request->boolean('join_before_host'),
@@ -96,6 +112,7 @@ class ConferenceController extends Controller
'green_room' => $request->boolean('green_room', true),
'practice_mode' => $request->boolean('practice_mode'),
'stage_mode' => true,
'presenter_refs' => $presenterRefs,
];
$data = array_merge($validated, [
@@ -115,7 +132,7 @@ class ConferenceController extends Controller
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
$invites = collect(preg_split('/[\s,;]+/', $emails))
->filter()
->map(fn ($email) => ['email' => $email])
->map(fn ($email) => ['email' => $email, 'role' => 'panelist'])
->all();
$this->invitations->inviteToRoom($room, $invites, $request->user());
}
@@ -135,7 +152,13 @@ class ConferenceController extends Controller
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
return view('meet.conferences.show', compact('room'));
$presenterRefs = (array) $room->setting('presenter_refs', []);
$speakers = Member::owned($this->ownerRef($request))
->where('organization_id', $room->organization_id)
->whereIn('user_ref', $presenterRefs)
->get();
return view('meet.conferences.show', compact('room', 'speakers'));
}
public function start(Request $request, Room $room): RedirectResponse
+20 -4
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\Room;
use App\Models\Session;
use App\Services\Meet\ConferenceService;
use App\Services\Meet\InvitationService;
use App\Services\Meet\SecurityPolicyService;
use App\Services\Meet\SessionService;
@@ -24,6 +25,7 @@ class JoinController extends Controller
protected SecurityPolicyService $security,
protected WebinarService $webinars,
protected SpaceService $spaces,
protected ConferenceService $conferences,
) {}
public function show(Request $request, Room $room): View|RedirectResponse
@@ -120,9 +122,11 @@ class JoinController extends Controller
$displayName = $user?->name ?? ($validated['display_name'] ?? 'Guest');
$email = $user?->email ?? ($validated['email'] ?? null);
$kind = $room->isConference() ? 'conference' : 'meeting';
[$allowed, $reason] = $this->security->canJoin($room, $user, $email ?? $displayName.'@guest.local');
if (! $allowed && $reason !== 'passcode_required') {
return back()->withErrors(['join' => $reason ?? 'Unable to join meeting.']);
return back()->withErrors(['join' => $reason ?? "Unable to join {$kind}."]);
}
try {
@@ -136,20 +140,20 @@ class JoinController extends Controller
if (! $room->activeSession() && $user && $user->ownerRef() === $room->host_user_ref) {
$session = $this->sessions->start($room, $user);
} elseif (! $room->activeSession()) {
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']);
} else {
$session = $room->activeSession();
}
} else {
if (! $room->activeSession()) {
return back()->withErrors(['join' => 'Meeting has not started yet. Please wait for the host.']);
return back()->withErrors(['join' => ucfirst($kind).' has not started yet. Please wait for the host.']);
}
$session = $room->activeSession();
}
} catch (\Symfony\Component\HttpKernel\Exception\HttpException $e) {
if (in_array($e->getStatusCode(), [402, 503], true)) {
return back()->withErrors(['join' => $e->getMessage() ?: 'Unable to start meeting.']);
return back()->withErrors(['join' => $e->getMessage() ?: "Unable to start {$kind}."]);
}
throw $e;
@@ -159,6 +163,8 @@ class JoinController extends Controller
if ($room->isSpace()) {
$role = $this->spaces->resolveJoinRole($room, $user, $role);
} elseif ($room->isConference() && $role !== 'host') {
$role = $this->conferences->resolveJoinRole($room, $user, $email, $role);
} elseif ($room->isWebinar() && $role !== 'host') {
$token = $request->session()->get("meet.webinar.{$room->uuid}");
$reg = $token
@@ -174,10 +180,20 @@ class JoinController extends Controller
$displayName = $reg->display_name;
}
if ($room->isConference() && ! $this->conferences->canJoinSession($room, $session, $role)) {
return back()->withErrors([
'join' => 'The conference has not gone live yet. Assigned speakers can join from the green room link when the host starts.',
]);
}
$joinStatus = $room->requiresWaitingRoom($user, $role) ? 'waiting' : 'joined';
$participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email, $joinStatus);
if ($room->isConference() && $session->session_mode === 'green_room') {
$this->conferences->registerPresenter($session, $user, $role);
}
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
$this->sessions->rememberParticipantSession($request, $participant, $session);
@@ -282,8 +282,32 @@ class MeetingFeaturesController extends Controller
{
$this->hostOnly($request, $session);
$live = $this->townHall->goLive($session, $request->user());
$participant = app(SessionService::class)->joinedParticipantForUser($live, $request->user());
return response()->json(['session_uuid' => $live->uuid, 'mode' => 'live']);
if ($participant) {
app(SessionService::class)->rememberParticipantSession($request, $participant, $live);
}
return response()->json([
'session_uuid' => $live->uuid,
'mode' => 'live',
'room_url' => route('meet.room', $live),
]);
}
public function addPresenter(Request $request, Session $session): JsonResponse
{
$this->hostOnly($request, $session);
abort_unless($session->room->isTownHall(), 422);
$validated = $request->validate(['user_ref' => ['required', 'string', 'max:64']]);
$updated = $this->townHall->addPresenter($session, $validated['user_ref']);
return response()->json([
'session' => $updated,
'presenter_refs' => $updated->presenter_refs,
]);
}
public function handoffCoHost(Request $request, Session $session): JsonResponse
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Meet;
use App\Http\Controllers\Controller;
use App\Models\Participant;
use App\Models\Recording;
use App\Models\Room;
use App\Models\Session;
use App\Services\Meet\ChatService;
use App\Services\Meet\Media\MediaProviderInterface;
@@ -37,8 +38,11 @@ class MeetingRoomController extends Controller
return redirect()->route('meet.ended', $session);
}
$room = $session->room;
$kind = $room->isConference() ? 'conference' : 'meeting';
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
abort_unless($participantUuid, 403, 'Please join the meeting first.');
abort_unless($participantUuid, 403, "Please join the {$kind} first.");
$participant = Participant::where('uuid', $participantUuid)
->where('session_id', $session->id)
@@ -48,7 +52,7 @@ class MeetingRoomController extends Controller
return redirect()->route('meet.join.waiting', $session->room);
}
abort_unless($participant->status === 'joined', 403, 'You are not in this meeting.');
abort_unless($participant->status === 'joined', 403, "You are not in this {$kind}.");
$token = $this->media->isConfigured()
? $this->sessions->generateMediaToken($participant)
@@ -72,6 +76,10 @@ class MeetingRoomController extends Controller
'activeRecording' => $session->recordings()->where('status', 'recording')->first(),
'watermark' => $room->organization?->securitySetting('watermark_enabled', false),
'isWebinar' => $room->isWebinar() || $room->isConference() || $room->isSpace(),
'isConference' => $room->isConference(),
'isGreenRoom' => $session->session_mode === 'green_room',
'sessionMode' => $session->session_mode,
'presenterRefs' => (array) $session->presenter_refs,
'isAudioOnly' => $isAudioOnly,
'isSpace' => $room->isSpace(),
'canPublish' => $room->isSpace()
@@ -91,10 +99,43 @@ class MeetingRoomController extends Controller
$this->sessions->end($session, $participant->user_ref ?? $participant->uuid);
$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.');
return redirect()->route($this->roomShowRoute($room), $room)
->with('success', $this->roomEndedMessage($room));
}
protected function roomShowRoute(Room $room): string
{
if ($room->isWebinar()) {
return 'meet.webinars.show';
}
if ($room->isConference()) {
return 'meet.conferences.show';
}
if ($room->isSpace()) {
return 'meet.spaces.show';
}
return 'meet.rooms.show';
}
protected function roomEndedMessage(Room $room): string
{
if ($room->isWebinar()) {
return 'Webinar ended.';
}
if ($room->isConference()) {
return 'Conference ended.';
}
if ($room->isSpace()) {
return 'Room closed.';
}
return 'Meeting ended.';
}
public function leave(Request $request, Session $session): RedirectResponse
@@ -17,6 +17,11 @@ class MailCalendarProvider
return null;
}
return $this->createEventForMailbox($mailbox, $room);
}
public function createEventForMailbox(string $mailbox, Room $room): ?string
{
$response = Http::withToken((string) config('meet.calendar.mail.api_key'))
->acceptJson()
->timeout(10)
@@ -32,13 +37,18 @@ class MailCalendarProvider
}
public function updateEvent(CalendarConnection $connection, Room $room, User $host): bool
{
$mailbox = $connection->calendar_id ?? $host->email;
return $mailbox ? $this->updateEventForMailbox($mailbox, $room) : false;
}
public function updateEventForMailbox(string $mailbox, Room $room): bool
{
if (! $room->calendar_event_id) {
return false;
}
$mailbox = $connection->calendar_id ?? $host->email;
return Http::withToken((string) config('meet.calendar.mail.api_key'))
->acceptJson()
->timeout(10)
@@ -47,6 +57,11 @@ class MailCalendarProvider
}
public function deleteEvent(CalendarConnection $connection, Room $room): bool
{
return $this->deleteEventForMailbox($connection->calendar_id, $room);
}
public function deleteEventForMailbox(?string $mailbox, Room $room): bool
{
if (! $room->calendar_event_id) {
return false;
@@ -55,10 +70,10 @@ class MailCalendarProvider
return Http::withToken((string) config('meet.calendar.mail.api_key'))
->acceptJson()
->timeout(10)
->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, [
'mailbox_email' => $connection->calendar_id,
->delete($this->baseUrl().'/calendar/events/'.$room->calendar_event_id, array_filter([
'mailbox_email' => $mailbox ? strtolower(trim($mailbox)) : null,
'external_ref' => $room->uuid,
])
]))
->successful();
}
+27 -8
View File
@@ -20,35 +20,54 @@ class CalendarService
public function syncRoomCreated(Room $room, User $host): void
{
$connection = $this->connectionFor($host);
if (! $connection) {
if (! $room->scheduled_at) {
return;
}
$eventId = $this->provider($connection)->createEvent($connection, $room, $host);
if ($host->email) {
$eventId = $this->mail->createEventForMailbox($host->email, $room);
if ($eventId) {
$room->update(['calendar_event_id' => $eventId]);
}
}
$connection = $this->connectionFor($host);
if ($connection && in_array($connection->provider, ['google', 'microsoft'], true)) {
$this->provider($connection)->createEvent($connection, $room, $host);
}
}
public function syncRoomUpdated(Room $room, User $host): void
{
$connection = $this->connectionFor($host);
if (! $connection || ! $room->calendar_event_id) {
if (! $room->scheduled_at) {
return;
}
if ($host->email) {
if ($room->calendar_event_id) {
$this->mail->updateEventForMailbox($host->email, $room);
} else {
$this->syncRoomCreated($room, $host);
}
}
$connection = $this->connectionFor($host);
if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) {
$this->provider($connection)->updateEvent($connection, $room, $host);
}
}
public function syncRoomCancelled(Room $room, User $host): void
{
$connection = $this->connectionFor($host);
if (! $connection || ! $room->calendar_event_id) {
return;
if ($host->email && $room->calendar_event_id) {
$this->mail->deleteEventForMailbox($host->email, $room);
}
$connection = $this->connectionFor($host);
if ($connection && $room->calendar_event_id && in_array($connection->provider, ['google', 'microsoft'], true)) {
$this->provider($connection)->deleteEvent($connection, $room);
}
$room->update(['calendar_event_id' => null]);
}
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace App\Services\Meet;
use App\Models\Invitation;
use App\Models\Participant;
use App\Models\Room;
use App\Models\Session;
use App\Models\User;
class ConferenceService
{
/**
* @return list<string>
*/
public function presenterRefs(Room $room): array
{
return array_values(array_unique(array_filter(
(array) $room->setting('presenter_refs', []),
)));
}
public function resolveJoinRole(Room $room, ?User $user, ?string $email, string $fallbackRole = 'guest'): string
{
if ($user && $user->ownerRef() === $room->host_user_ref) {
return 'host';
}
$presenterRefs = $this->presenterRefs($room);
if ($user && in_array($user->ownerRef(), $presenterRefs, true)) {
return 'panelist';
}
if ($email && $this->invitationRole($room, $email) === 'panelist') {
return 'panelist';
}
if ($room->isTownHall()) {
return 'attendee';
}
return $fallbackRole;
}
public function canJoinSession(Room $room, Session $session, string $role): bool
{
if ($session->session_mode !== 'green_room') {
return true;
}
return in_array($role, ['host', 'co_host', 'panelist'], true);
}
public function isPresenter(Session $session, Participant $participant): bool
{
if (in_array($participant->role, ['host', 'co_host', 'panelist'], true)) {
return true;
}
$refs = (array) $session->presenter_refs;
return $participant->user_ref && in_array($participant->user_ref, $refs, true);
}
public function canPublish(Room $room, Participant $participant): bool
{
return in_array($participant->role, ['host', 'co_host', 'panelist'], true);
}
public function registerPresenter(Session $session, ?User $user, string $role): void
{
if (! $user || ! in_array($role, ['host', 'co_host', 'panelist'], true)) {
return;
}
app(TownHallService::class)->addPresenter($session, $user->ownerRef());
}
protected function invitationRole(Room $room, string $email): ?string
{
return Invitation::query()
->where('room_id', $room->id)
->where('email', strtolower(trim($email)))
->value('role');
}
}
@@ -36,6 +36,7 @@ class ParticipantPresenter
{
return [
'uuid' => $participant->uuid,
'user_ref' => $participant->user_ref,
'display_name' => $participant->display_name,
'role' => $participant->role,
'status' => $participant->status,
+5 -4
View File
@@ -13,20 +13,21 @@ class SecurityPolicyService
{
$org = $room->organization;
$policy = $this->mergedPolicy($org, $room);
$kind = $room->isConference() ? 'conference' : 'meeting';
if ($policy['org_only'] && ! $user) {
return [false, 'This meeting requires a Ladill account.'];
return [false, "This {$kind} requires a Ladill account."];
}
if ($policy['authenticated_only'] && ! $user) {
return [false, 'Please sign in to join this meeting.'];
return [false, "Please sign in to join this {$kind}."];
}
if (! empty($policy['allowed_domains']) && $email) {
$domain = Str($email)->after('@')->lower()->toString();
$allowed = collect($policy['allowed_domains'])->map(fn ($d) => strtolower(trim($d)))->filter();
if ($allowed->isNotEmpty() && ! $allowed->contains($domain)) {
return [false, 'Your email domain is not allowed to join this meeting.'];
return [false, "Your email domain is not allowed to join this {$kind}."];
}
}
@@ -35,7 +36,7 @@ class SecurityPolicyService
}
if ($room->activeSession()?->is_locked && ! $this->isHost($room, $user)) {
return [false, 'This meeting is locked.'];
return [false, "This {$kind} is locked."];
}
if ($policy['recording_host_only'] && ! $this->isHost($room, $user)) {
+1 -1
View File
@@ -2,5 +2,5 @@
return [
'product_slug' => 'meet',
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com'),
'marketing_url' => env('LADILL_MARKETING_URL', 'https://ladill.com/products/meet'),
];
+11
View File
@@ -41,6 +41,16 @@ return [
'monthly' => 'Monthly',
],
'schedule_timezones' => [
'UTC',
'America/New_York',
'America/Chicago',
'America/Los_Angeles',
'Europe/London',
'Africa/Accra',
'Africa/Lagos',
],
'participant_roles' => [
'host' => 'Host',
'co_host' => 'Co-host',
@@ -139,6 +149,7 @@ return [
'frontdesk' => env('MEET_API_KEY_FRONTDESK'),
'hr' => env('MEET_API_KEY_HR'),
'support' => env('MEET_API_KEY_SUPPORT'),
'invoice' => env('MEET_API_KEY_INVOICE'),
],
'webhook_events' => [
+71 -3
View File
@@ -38,6 +38,10 @@ function meetRoom() {
needsMediaUnlock: false,
waitingCount: 0,
isWebinar: false,
isConference: false,
isGreenRoom: false,
goingLive: false,
presenterRefs: [],
audioOnly: false,
isSpace: false,
qaItems: [],
@@ -96,6 +100,8 @@ function meetRoom() {
filesUploadUrl: el.dataset.filesUploadUrl,
whiteboardUrl: el.dataset.whiteboardUrl,
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
goLiveUrl: el.dataset.goLiveUrl,
addPresenterUrl: el.dataset.addPresenterUrl,
csrf: el.dataset.csrf,
sessionUuid: el.dataset.sessionUuid,
};
@@ -107,6 +113,8 @@ function meetRoom() {
this.isHost = el.dataset.isHost === '1';
this.canPublish = el.dataset.canPublish !== '0';
this.isWebinar = el.dataset.isWebinar === '1';
this.isConference = el.dataset.isConference === '1';
this.isGreenRoom = el.dataset.isGreenRoom === '1';
this.audioOnly = el.dataset.audioOnly === '1';
this.isSpace = el.dataset.isSpace === '1';
this.muteOnJoin = el.dataset.muteOnJoin === '1';
@@ -117,6 +125,12 @@ function meetRoom() {
this.liveCaptions = el.dataset.liveCaptions === '1';
this.displayName = el.dataset.displayName || '';
try {
this.presenterRefs = JSON.parse(el.dataset.presenterRefs || '[]');
} catch {
this.presenterRefs = [];
}
try {
this.sessionParticipants = JSON.parse(el.dataset.initialParticipants || '[]');
} catch {
@@ -888,10 +902,10 @@ function meetRoom() {
if (blob && recordingUuid) {
const uploaded = await this.uploadRecording(blob, recordingUuid);
if (!uploaded) {
window.alert('Recording upload failed. Open Recordings and try again, or re-record the meeting.');
window.alert(`Recording upload failed. Open Recordings and try again, or re-record the ${this.sessionNoun()}.`);
}
} else if (recordingUuid) {
window.alert('No recording was captured from this meeting. Start recording again while the meeting is live.');
window.alert(`No recording was captured from this ${this.sessionNoun()}. Start recording again while the ${this.sessionNoun()} is live.`);
}
this.activeRecordingUuid = null;
@@ -1202,6 +1216,52 @@ function meetRoom() {
});
},
async goLive() {
if (!this.config.goLiveUrl || this.goingLive) {
return;
}
this.goingLive = true;
try {
const res = await fetch(this.config.goLiveUrl, {
method: 'POST',
headers: this.headers(),
});
if (!res.ok) {
throw new Error('Go live failed');
}
const data = await res.json();
window.location.href = data.room_url || `/room/${data.session_uuid}`;
} catch {
this.goingLive = false;
}
},
async makeSpeaker(userRef) {
if (!this.config.addPresenterUrl || !userRef) {
return;
}
const res = await fetch(this.config.addPresenterUrl, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ user_ref: userRef }),
});
if (! res.ok) {
return;
}
const data = await res.json();
this.presenterRefs = data.presenter_refs || this.presenterRefs;
this.sessionParticipants = this.sessionParticipants.map((person) => (
person.user_ref === userRef ? { ...person, role: 'panelist' } : person
));
},
toggleChat() {
this.chatOpen = !this.chatOpen;
if (this.chatOpen) {
@@ -1238,7 +1298,7 @@ function meetRoom() {
try {
await navigator.share({
title: this.roomTitle || 'Ladill Meet',
text: 'Join my meeting on Ladill Meet',
text: `Join my ${this.sessionNoun()} on Ladill Meet`,
url: this.joinUrl,
});
@@ -1329,6 +1389,14 @@ function meetRoom() {
}
},
sessionNoun() {
return this.isConference ? 'conference' : 'meeting';
},
sessionNounTitle() {
return this.isConference ? 'Conference' : 'Meeting';
},
redirectToMeetingEnded(url) {
if (this.pollTimer) {
clearInterval(this.pollTimer);
@@ -41,7 +41,7 @@
<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)
@foreach (config('meet.schedule_timezones') as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
@@ -49,10 +49,25 @@
</div>
<div>
<label class="block text-sm font-medium text-slate-700">Invite by email (comma-separated)</label>
<label class="block text-sm font-medium text-slate-700">Invite speakers by email (comma-separated)</label>
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="speaker@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
</div>
@if ($members->isNotEmpty())
<div>
<label class="block text-sm font-medium text-slate-700">Assigned speakers</label>
<p class="mt-0.5 text-xs text-slate-500">Team members who can speak on stage. You are always the host.</p>
<div class="mt-2 max-h-40 space-y-2 overflow-y-auto rounded-lg border border-slate-200 p-3">
@foreach ($members as $member)
<label class="flex items-center gap-2 text-sm">
<input type="checkbox" name="presenter_refs[]" value="{{ $member->user_ref }}" @checked(in_array($member->user_ref, old('presenter_refs', []))) class="rounded border-slate-300">
<span>{{ $member->user_ref }}</span>
</label>
@endforeach
</div>
</div>
@endif
<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">
+24 -10
View File
@@ -12,14 +12,6 @@
</div>
@endif
<div class="mt-4 rounded-xl border border-violet-100 bg-violet-50 px-4 py-3 text-sm text-violet-900">
Multi-speaker event with assigned hosts and presenters.
Billed at <span class="font-medium">GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant</span>.
@if ($room->setting('green_room'))
Green room is enabled presenters rehearse before the audience joins live.
@endif
</div>
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-medium text-slate-700">Join link</h2>
<div class="mt-2 flex gap-2">
@@ -59,8 +51,30 @@
</p>
@endif
@if ($room->description)
<p class="mt-3 text-sm text-slate-600">{{ $room->description }}</p>
@if ($room->setting('green_room'))
<p class="mt-3 text-sm text-violet-700">Green room is on when you start, you and assigned speakers rehearse before going live to the audience.</p>
@endif
@if ($speakers->isNotEmpty())
<div class="mt-4">
<h3 class="text-sm font-medium text-slate-700">Assigned speakers</h3>
<ul class="mt-2 space-y-1 text-sm text-slate-600">
@foreach ($speakers as $speaker)
<li>{{ $speaker->user_ref }}</li>
@endforeach
</ul>
</div>
@endif
@if ($room->invitations->where('role', 'panelist')->isNotEmpty())
<div class="mt-4">
<h3 class="text-sm font-medium text-slate-700">Invited speakers</h3>
<ul class="mt-2 space-y-1 text-sm text-slate-600">
@foreach ($room->invitations->where('role', 'panelist') as $invite)
<li>{{ $invite->email }}</li>
@endforeach
</ul>
</div>
@endif
</div>
+3 -3
View File
@@ -1,4 +1,4 @@
<x-meet-kiosk-layout :title="'Meeting ended · '.$room->title" :organization="$organization">
<x-meet-kiosk-layout :title="($room->isConference() ? 'Conference ended' : 'Meeting ended').' · '.$room->title" :organization="$organization">
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 text-slate-600">
@@ -6,8 +6,8 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9"/>
</svg>
</div>
<h1 class="mt-6 text-2xl font-bold text-slate-900">Meeting ended</h1>
<p class="mt-3 text-sm text-slate-600">The host has ended this meeting.</p>
<h1 class="mt-6 text-2xl font-bold text-slate-900">{{ $room->isConference() ? 'Conference ended' : 'Meeting ended' }}</h1>
<p class="mt-3 text-sm text-slate-600">The host has ended this {{ $room->isConference() ? 'conference' : 'meeting' }}.</p>
<p class="mt-1 text-sm font-medium text-slate-700">{{ $room->title }}</p>
<div class="mt-8 flex flex-col gap-3">
@@ -1,8 +1,8 @@
<x-meet-kiosk-layout :title="'Meeting cancelled'" :organization="$organization ?? null">
<x-meet-kiosk-layout :title="($room->isConference() ? 'Conference cancelled' : 'Meeting cancelled')" :organization="$organization ?? null">
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
<p class="mt-3 text-sm text-slate-600">This meeting has been cancelled by the host.</p>
<p class="mt-3 text-sm text-slate-600">This {{ $room->isConference() ? 'conference' : 'meeting' }} has been cancelled by the host.</p>
</div>
</div>
</x-meet-kiosk-layout>
+1 -1
View File
@@ -7,7 +7,7 @@
</svg>
</div>
<h1 class="mt-6 text-2xl font-bold text-slate-900">Unable to join</h1>
<p class="mt-3 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this meeting.' }}</p>
<p class="mt-3 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this '.($room->isConference() ? 'conference' : 'meeting').'.' }}</p>
<a href="{{ route('sso.connect', ['redirect' => route('meet.join', $room), 'interactive' => 1]) }}" class="btn-primary btn-primary-lg mt-8 inline-flex w-full items-center justify-center py-3.5 font-semibold">
Sign in with Ladill
</a>
+2 -2
View File
@@ -1,8 +1,8 @@
<x-meet-kiosk-layout :title="'Passcode · '.$room->title" :organization="$organization" eyebrow="Secure meeting">
<x-meet-kiosk-layout :title="'Passcode · '.$room->title" :organization="$organization" eyebrow="{{ $room->isConference() ? 'Secure conference' : 'Secure meeting' }}">
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center">
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
<p class="mt-2 text-sm text-slate-500">This meeting is protected. Enter the passcode to continue.</p>
<p class="mt-2 text-sm text-slate-500">This {{ $room->isConference() ? 'conference' : 'meeting' }} is protected. Enter the passcode to continue.</p>
@if ($errors->any())
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
+5 -2
View File
@@ -1,4 +1,7 @@
<x-meet-kiosk-layout :title="'Join · '.$room->title" :organization="$organization">
@php
$sessionNoun = $room->isConference() ? 'conference' : 'meeting';
@endphp
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
<div class="w-full max-w-lg">
<h1 class="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{{ $room->title }}</h1>
@@ -47,11 +50,11 @@
@endauth
@if ($room->setting('waiting_room', true) && ! ($user && $user->ownerRef() === $room->host_user_ref))
<p class="mt-4 text-xs text-slate-500">The host will admit you when the meeting is ready.</p>
<p class="mt-4 text-xs text-slate-500">The host will admit you when the {{ $sessionNoun }} is ready.</p>
@endif
<button type="submit" class="btn-primary btn-primary-lg mt-8 w-full py-4 text-base font-semibold">
Join meeting
Join {{ $sessionNoun }}
</button>
</form>
+2 -2
View File
@@ -6,7 +6,7 @@
@csrf
<div class="text-center">
<h1 class="text-2xl font-bold text-slate-900">You left the meeting</h1>
<h1 class="text-2xl font-bold text-slate-900">You left the {{ $room->isConference() ? 'conference' : 'meeting' }}</h1>
<p class="mt-2 text-sm text-slate-500">{{ $room->title }}</p>
@if (! empty($feedback['display_name']))
<p class="mt-1 text-sm text-slate-600">Thanks, {{ $feedback['display_name'] }}.</p>
@@ -14,7 +14,7 @@
</div>
<div class="mt-8">
<p class="text-sm font-medium text-slate-700">How was the meeting overall?</p>
<p class="text-sm font-medium text-slate-700">How was the {{ $room->isConference() ? 'conference' : 'meeting' }} overall?</p>
<div class="mt-3 flex justify-center gap-2">
@foreach (range(1, 5) as $star)
<button type="button"
+2 -2
View File
@@ -10,7 +10,7 @@
@if (session('rated'))
Thanks for your feedback
@else
You have left the meeting
You have left the {{ $room->isConference() ? 'conference' : 'meeting' }}
@endif
</h1>
<p class="mt-3 text-sm text-slate-600">{{ $room->title }}</p>
@@ -19,7 +19,7 @@
@auth
<a href="{{ route('meet.dashboard') }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Back to Meet</a>
@else
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Rejoin meeting</a>
<a href="{{ route('meet.join', $room) }}" class="btn-primary btn-primary-lg py-3.5 font-semibold">Rejoin {{ $room->isConference() ? 'conference' : 'meeting' }}</a>
@endauth
</div>
</div>
@@ -25,6 +25,8 @@
<p class="mt-1 text-sm text-slate-500">
@if ($room->scheduled_at)
{{ $room->scheduled_at->timezone($room->timezone)->format('M j, Y g:i A T') }}
@elseif ($room->isConference())
Instant conference
@else
Instant meeting
@endif
@@ -1,5 +1,12 @@
@php
$room = $recording->session->room;
$sessionNoun = $room->isConference() ? 'conference' : 'meeting';
$roomDetailsRoute = match (true) {
$room->isConference() => route('meet.conferences.show', $room),
$room->isWebinar() => route('meet.webinars.show', $room),
$room->isSpace() => route('meet.spaces.show', $room),
default => route('meet.rooms.show', $room),
};
$statusLabel = config('meet.recording_statuses')[$recording->status] ?? ucfirst($recording->status);
$statusClass = match ($recording->status) {
'ready' => 'bg-emerald-50 text-emerald-700',
@@ -80,7 +87,7 @@
</svg>
<div>
<p class="text-sm font-medium text-amber-900">Processing recording</p>
<p class="mt-0.5 text-sm text-amber-800">This usually takes a moment after the meeting ends. Refresh shortly.</p>
<p class="mt-0.5 text-sm text-amber-800">This usually takes a moment after the {{ $sessionNoun }} ends. Refresh shortly.</p>
</div>
</div>
@elseif ($recording->status === 'failed')
@@ -98,7 +105,7 @@
Download
</a>
@endif
<a href="{{ route('meet.rooms.show', $room) }}" class="btn-secondary btn-secondary-sm">View meeting</a>
<a href="{{ $roomDetailsRoute }}" class="btn-secondary btn-secondary-sm">View {{ $sessionNoun }}</a>
@if ($summary)
<a href="{{ route('meet.summaries.show', $summary) }}" class="btn-secondary btn-secondary-sm">AI summary</a>
@endif
+45 -13
View File
@@ -24,6 +24,9 @@
'display_name' => $roomHostUser?->name ?? 'Host',
'avatar_url' => $roomHostUser?->avatarUrl(),
];
$isConferenceSession = $isConference ?? false;
$sessionNoun = $isConferenceSession ? 'conference' : 'meeting';
$sessionNounTitle = $isConferenceSession ? 'Conference' : 'Meeting';
@endphp
<div id="meet-config"
data-token="{{ $mediaToken }}"
@@ -53,6 +56,12 @@
data-locked="{{ $session->is_locked ? '1' : '0' }}"
data-recording-active="{{ $activeRecording ? '1' : '0' }}"
data-is-webinar="{{ ($isWebinar ?? false) ? '1' : '0' }}"
data-is-conference="{{ ($isConference ?? false) ? '1' : '0' }}"
data-is-green-room="{{ ($isGreenRoom ?? false) ? '1' : '0' }}"
data-session-mode="{{ $session->session_mode }}"
data-go-live-url="{{ ($isConference ?? false) && ($isGreenRoom ?? false) && $participant->isHost() ? route('meet.room.townhall.live', $session) : '' }}"
data-add-presenter-url="{{ ($isConference ?? false) && $participant->isHost() ? route('meet.room.townhall.presenters', $session) : '' }}"
data-presenter-refs='@json($presenterRefs ?? [])'
data-can-publish="{{ ($canPublish ?? true) ? '1' : '0' }}"
data-qa-url="{{ route('meet.room.qa.submit', $session) }}"
data-polls-create-url="{{ route('meet.room.polls.create', $session) }}"
@@ -84,6 +93,10 @@
<h1 class="truncate text-sm font-semibold">{{ $room->title }}</h1>
@if ($isAudioOnly ?? false)
<p class="text-[10px] font-medium uppercase tracking-wide text-emerald-400/90">Audio room</p>
@elseif ($isConference ?? false)
<p class="text-[10px] font-medium uppercase tracking-wide"
:class="isGreenRoom ? 'text-violet-400/90' : 'text-sky-400/90'"
x-text="isGreenRoom ? 'Green room' : 'Conference live'"></p>
@endif
<p class="text-xs text-slate-400" x-text="connectionStatus"></p>
<p class="min-h-4 truncate text-xs font-medium leading-4"
@@ -94,7 +107,7 @@
<div class="flex shrink-0 items-center gap-2 text-xs text-slate-400">
<button @click.stop="openShare()" data-share-trigger type="button"
class="inline-flex items-center gap-1.5 rounded-lg bg-slate-900 px-2.5 py-1.5 text-xs font-medium text-slate-200 transition-colors hover:bg-slate-800"
title="Share meeting link">
title="Share {{ $sessionNoun }} link">
@include('meet.room.partials.meet-icon', ['icon' => 'share', 'class' => 'h-4 w-4'])
Share
</button>
@@ -122,6 +135,18 @@
</div>
</header>
<div x-show="isConference && isGreenRoom" x-cloak
class="flex shrink-0 items-center justify-between gap-3 border-b border-violet-500/30 bg-violet-500/15 px-4 py-2.5">
<div class="min-w-0 text-sm text-violet-100">
<p class="font-medium">Green room</p>
<p class="truncate text-xs text-violet-200/90">Only hosts and speakers are visible. Attendees join after you go live.</p>
</div>
<button x-show="isHost" @click="goLive()" type="button" :disabled="goingLive"
class="shrink-0 rounded-lg bg-violet-500 px-3 py-1.5 text-xs font-semibold text-white transition-colors hover:bg-violet-400 disabled:opacity-60">
<span x-text="goingLive ? 'Starting…' : 'Go live'">Go live</span>
</button>
</div>
<div x-show="isHost && waitingCount > 0" x-cloak
class="flex shrink-0 items-center justify-between gap-3 border-b border-amber-500/30 bg-amber-500/15 px-4 py-2.5">
<div class="flex min-w-0 items-center gap-2 text-sm text-amber-100">
@@ -151,7 +176,7 @@
<div class="flex items-start justify-between gap-2">
<div>
<p class="text-sm font-medium text-white">Invite others</p>
<p class="mt-0.5 text-xs text-slate-400">Share this link so people can join the meeting.</p>
<p class="mt-0.5 text-xs text-slate-400">Share this link so people can join the {{ $sessionNoun }}.</p>
</div>
<button @click="shareOpen = false" type="button" class="rounded p-1 text-slate-400 hover:bg-slate-800 hover:text-white" title="Close">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
@@ -254,8 +279,8 @@
@csrf
<button type="submit"
class="rounded-full bg-red-600 p-3 font-medium hover:bg-red-500 sm:px-5 sm:py-3 sm:text-sm"
title="Leave meeting"
aria-label="Leave meeting">
title="Leave {{ $sessionNoun }}"
aria-label="Leave {{ $sessionNoun }}">
<span class="sm:hidden">
@include('meet.room.partials.meet-icon', ['icon' => 'leave'])
</span>
@@ -265,8 +290,8 @@
@if ($participant->isHost())
<button @click="openEndMeetingConfirm()" type="button"
class="rounded-full bg-red-950 p-3 text-red-400 hover:bg-red-900 sm:px-4 sm:py-3 sm:text-sm"
title="End meeting for everyone"
aria-label="End meeting for everyone">
title="End {{ $sessionNoun }} for everyone"
aria-label="End {{ $sessionNoun }} for everyone">
<span class="sm:hidden">
@include('meet.room.partials.meet-icon', ['icon' => 'end'])
</span>
@@ -307,19 +332,19 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
@keydown.escape.window="endMeetingConfirmOpen = false">
<div class="w-full max-w-sm rounded-2xl bg-slate-900 p-5 shadow-2xl" @click.outside="endMeetingConfirmOpen = false">
<h3 class="text-base font-semibold text-white">End meeting for everyone?</h3>
<h3 class="text-base font-semibold text-white">End {{ $sessionNoun }} for everyone?</h3>
<p class="mt-2 text-sm text-slate-400">All participants will be disconnected and the session will close.</p>
<div class="mt-5 flex justify-end gap-2">
<button type="button" @click="endMeetingConfirmOpen = false"
class="rounded-xl px-4 py-2 text-sm font-medium text-slate-300 hover:bg-slate-800">Cancel</button>
<button type="button" @click="submitEndMeeting()"
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End meeting</button>
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-500">End {{ $sessionNoun }}</button>
</div>
</div>
</div>
</div>
<x-meet.room.panel show="chatOpen" title="Meeting chat">
<x-meet.room.panel show="chatOpen" title="{{ $sessionNounTitle }} chat">
<x-slot:subtitle>
<p>Messages are visible to everyone in the call</p>
</x-slot:subtitle>
@@ -403,6 +428,13 @@
<span x-show="isSpeaking(person.uuid)" class="block text-xs font-medium text-emerald-400">Speaking</span>
<span x-show="!isSpeaking(person.uuid)" class="block text-xs text-slate-400" x-text="roleLabel(person.role)"></span>
</span>
<span class="flex shrink-0 items-center gap-1">
<button x-show="isConference && isHost && isGreenRoom && person.user_ref && !['host', 'co_host', 'panelist'].includes(person.role)"
type="button" @click="makeSpeaker(person.user_ref)"
class="rounded-lg bg-violet-600 px-2 py-1 text-[10px] font-medium text-white hover:bg-violet-500">
Make speaker
</button>
</span>
<span class="flex shrink-0 items-center gap-1 text-slate-400">
<span x-show="isSpeaking(person.uuid)" class="meet-speaking-badge rounded-full bg-emerald-500/20 p-1 text-emerald-300" title="Speaking">
<svg class="h-3.5 w-3.5" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
@@ -429,7 +461,7 @@
<x-meet.room.panel show="featuresOpen" title="More options">
<x-slot:subtitle>
<p>Meeting tools and extras</p>
<p>{{ $sessionNounTitle }} tools and extras</p>
</x-slot:subtitle>
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto">
@@ -441,7 +473,7 @@
@click="sendReaction('thumbs'); featuresOpen = false" />
<x-meet.room.menu-item icon="react" title="React" subtitle="Send a heart to the room"
@click="sendReaction('heart'); featuresOpen = false" />
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the meeting chat"
<x-meet.room.menu-item icon="chat" title="Chat" subtitle="Open the {{ $sessionNoun }} chat"
@click="toggleChat(); featuresOpen = false" />
<div class="my-1 border-t border-slate-800"></div>
</div>
@@ -465,7 +497,7 @@
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isRecording ? 'Stop recording' : 'Start recording'"></span>
<span class="block text-xs text-slate-400">Save this meeting to the cloud</span>
<span class="block text-xs text-slate-400">Save this {{ $sessionNoun }} to the cloud</span>
</span>
</button>
@@ -475,7 +507,7 @@
@include('meet.room.partials.meet-icon', ['icon' => 'lock', 'class' => 'h-5 w-5'])
</span>
<span class="min-w-0">
<span class="block font-medium" x-text="isLocked ? 'Unlock meeting' : 'Lock meeting'"></span>
<span class="block font-medium" x-text="isLocked ? 'Unlock ' + sessionNoun() : 'Lock ' + sessionNoun()"></span>
<span class="block text-xs text-slate-400">Control who can join</span>
</span>
</button>
+1 -1
View File
@@ -40,7 +40,7 @@
<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)
@foreach (config('meet.schedule_timezones') as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
@@ -41,7 +41,7 @@
<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)
@foreach (config('meet.schedule_timezones') as $tz)
<option value="{{ $tz }}" @selected(old('timezone', $organization->timezone) === $tz)>{{ $tz }}</option>
@endforeach
</select>
+2 -1
View File
@@ -32,7 +32,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/', fn () => auth()->check()
? redirect()->route('meet.dashboard')
: redirect()->route('sso.connect', ['interactive' => 1]))->name('meet.root');
: redirect()->route('sso.connect'))->name('meet.root');
Route::get('/login', [SsoLoginController::class, 'connect'])->name('login');
Route::get('/sso/connect', [SsoLoginController::class, 'connect'])->name('sso.connect');
@@ -99,6 +99,7 @@ Route::post('/room/{session}/live-stream/start', [MeetingFeaturesController::cla
Route::post('/room/{session}/live-stream/stop', [MeetingFeaturesController::class, 'stopLiveStream'])->name('meet.room.livestream.stop');
Route::post('/room/{session}/town-hall/green-room', [MeetingFeaturesController::class, 'startGreenRoom'])->name('meet.room.townhall.green');
Route::post('/room/{session}/town-hall/go-live', [MeetingFeaturesController::class, 'goLiveTownHall'])->name('meet.room.townhall.live');
Route::post('/room/{session}/town-hall/presenters', [MeetingFeaturesController::class, 'addPresenter'])->name('meet.room.townhall.presenters');
Route::post('/room/{session}/town-hall/handoff', [MeetingFeaturesController::class, 'handoffCoHost'])->name('meet.room.townhall.handoff');
Route::middleware(['auth', 'platform.session'])->group(function () {
+130
View File
@@ -67,6 +67,20 @@ class MeetWebTest extends TestCase
$this->get('/dashboard')->assertRedirect();
}
public function test_guest_root_uses_silent_sso_not_interactive_auth(): void
{
$response = $this->get('/');
$response->assertRedirect(route('sso.connect'));
$this->assertStringNotContainsString('interactive=1', (string) $response->headers->get('Location'));
}
public function test_sso_callback_sends_guest_without_session_to_marketing_page(): void
{
$this->get('/sso/callback?error=login_required')
->assertRedirect(config('ladill.marketing_url'));
}
public function test_onboarded_user_can_view_dashboard(): void
{
$this->actingAs($this->user)
@@ -464,6 +478,7 @@ class MeetWebTest extends TestCase
public function test_service_api_can_create_room(): void
{
\Illuminate\Support\Facades\Http::fake();
config(['meet.service_api_keys.care' => 'test-care-key']);
$this->postJson('/api/service/v1/rooms', [
@@ -482,6 +497,51 @@ class MeetWebTest extends TestCase
]);
}
public function test_service_api_resolves_organization_from_owner_ref(): void
{
\Illuminate\Support\Facades\Http::fake();
config(['meet.service_api_keys.crm' => 'test-crm-key']);
$this->postJson('/api/service/v1/rooms', [
'owner_ref' => $this->user->public_id,
'host_user_ref' => $this->user->public_id,
'title' => 'CRM follow-up',
'scheduled_at' => now()->addDay()->toIso8601String(),
'source' => ['app' => 'crm', 'entity_type' => 'deal', 'entity_id' => '42'],
], ['Authorization' => 'Bearer test-crm-key'])
->assertCreated()
->assertJsonPath('room.organization_id', $this->organization->id);
}
public function test_scheduled_room_syncs_to_ladill_mail_calendar(): void
{
\Illuminate\Support\Facades\Http::fake([
'mail.ladill.com/*' => \Illuminate\Support\Facades\Http::response(['data' => ['id' => 99]], 201),
]);
config([
'meet.calendar.mail.api_url' => 'https://mail.ladill.com/api/service/v1',
'meet.calendar.mail.api_key' => 'mail-test-key',
]);
$this->actingAs($this->user)
->post('/meetings', [
'title' => 'Calendar sync test',
'scheduled_at' => now()->addDay()->format('Y-m-d H:i'),
'duration_minutes' => 30,
])
->assertRedirect();
$room = \App\Models\Room::where('title', 'Calendar sync test')->first();
$this->assertNotNull($room);
$this->assertSame('99', (string) $room->calendar_event_id);
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), '/calendar/events')
&& $request['mailbox_email'] === strtolower($this->user->email);
});
}
public function test_invitation_rsvp_accepts(): void
{
$room = $this->createRoom();
@@ -614,6 +674,55 @@ class MeetWebTest extends TestCase
]);
}
public function test_host_can_end_conference_and_view_details(): void
{
$room = Room::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'host_user_ref' => $this->user->public_id,
'title' => 'NextGen Summit',
'type' => 'town_hall',
'status' => 'live',
'scheduled_at' => now()->addHour(),
'timezone' => 'UTC',
'settings' => array_merge(config('meet.default_settings'), [
'green_room' => true,
'stage_mode' => true,
'presenter_refs' => [],
]),
]);
$session = Session::create([
'owner_ref' => $this->user->public_id,
'room_id' => $room->id,
'media_room_name' => 'meet-'.$room->uuid,
'status' => 'live',
'session_mode' => 'live',
'started_at' => now(),
]);
$participant = \App\Models\Participant::create([
'owner_ref' => $this->user->public_id,
'session_id' => $session->id,
'user_ref' => $this->user->public_id,
'display_name' => $this->user->name,
'role' => 'host',
'status' => 'joined',
'joined_at' => now(),
]);
$this->actingAs($this->user)
->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
->post('/room/'.$session->uuid.'/end')
->assertRedirect(route('meet.conferences.show', $room))
->assertSessionHas('success', 'Conference ended.');
$this->actingAs($this->user)
->get(route('meet.conferences.show', $room))
->assertOk()
->assertSee('NextGen Summit');
}
public function test_host_can_create_space_room(): void
{
$this->actingAs($this->user)
@@ -629,6 +738,27 @@ class MeetWebTest extends TestCase
]);
}
public function test_afia_chat_returns_reply_when_ai_configured(): void
{
config([
'afia.api_key' => 'test-openai-key',
'afia.platform_api_key' => '',
]);
Http::fake([
'api.openai.com/*' => Http::response([
'choices' => [['message' => ['content' => 'Go to Meetings and click Schedule meeting.']]],
]),
]);
$this->createRoom(['title' => 'Upcoming sync']);
$this->actingAs($this->user)
->postJson('/ai/chat', ['message' => 'How do I schedule a meeting?'])
->assertOk()
->assertJson(['reply' => 'Go to Meetings and click Schedule meeting.']);
}
protected function createRoom(array $overrides = []): Room
{
return Room::create(array_merge([