Add kiosk join/leave pages, waiting room, and meeting feedback.
Deploy Ladill Meet / deploy (push) Successful in 47s
Deploy Ladill Meet / deploy (push) Successful in 47s
Redesign public join flows on the Frontdesk kiosk template, enforce host admission when waiting room is enabled, and collect star ratings after leave. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3,11 +3,14 @@
|
|||||||
namespace App\Http\Controllers\Meet;
|
namespace App\Http\Controllers\Meet;
|
||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Participant;
|
||||||
use App\Models\Room;
|
use App\Models\Room;
|
||||||
|
use App\Models\Session;
|
||||||
use App\Services\Meet\InvitationService;
|
use App\Services\Meet\InvitationService;
|
||||||
use App\Services\Meet\SecurityPolicyService;
|
use App\Services\Meet\SecurityPolicyService;
|
||||||
use App\Services\Meet\SessionService;
|
use App\Services\Meet\SessionService;
|
||||||
use App\Services\Meet\WebinarService;
|
use App\Services\Meet\WebinarService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -23,8 +26,13 @@ class JoinController extends Controller
|
|||||||
|
|
||||||
public function show(Request $request, Room $room): View|RedirectResponse
|
public function show(Request $request, Room $room): View|RedirectResponse
|
||||||
{
|
{
|
||||||
|
$room->loadMissing('organization');
|
||||||
|
|
||||||
if ($room->status === 'cancelled') {
|
if ($room->status === 'cancelled') {
|
||||||
return view('meet.join.cancelled', compact('room'));
|
return view('meet.join.cancelled', [
|
||||||
|
'room' => $room,
|
||||||
|
'organization' => $room->organization,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($room->isWebinar() && $request->query('token')) {
|
if ($room->isWebinar() && $request->query('token')) {
|
||||||
@@ -33,15 +41,39 @@ class JoinController extends Controller
|
|||||||
|
|
||||||
[$allowed, $reason] = $this->security->canJoin($room, $request->user(), $request->user()?->email);
|
[$allowed, $reason] = $this->security->canJoin($room, $request->user(), $request->user()?->email);
|
||||||
if (! $allowed && $reason !== 'passcode_required') {
|
if (! $allowed && $reason !== 'passcode_required') {
|
||||||
return view('meet.join.denied', compact('room', 'reason'));
|
return view('meet.join.denied', [
|
||||||
|
'room' => $room,
|
||||||
|
'organization' => $room->organization,
|
||||||
|
'reason' => $reason,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) {
|
if ($room->passcode && ! $request->session()->get("meet.passcode.{$room->uuid}")) {
|
||||||
return view('meet.join.passcode', compact('room'));
|
return view('meet.join.passcode', [
|
||||||
|
'room' => $room,
|
||||||
|
'organization' => $room->organization,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$session = $room->activeSession();
|
||||||
|
if ($session) {
|
||||||
|
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||||
|
$participant = $participantUuid
|
||||||
|
? Participant::where('uuid', $participantUuid)->where('session_id', $session->id)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if ($participant?->status === 'waiting') {
|
||||||
|
return redirect()->route('meet.join.waiting', $room);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($participant?->status === 'joined') {
|
||||||
|
return redirect()->route('meet.room', $session);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return view('meet.join.show', [
|
return view('meet.join.show', [
|
||||||
'room' => $room,
|
'room' => $room,
|
||||||
|
'organization' => $room->organization,
|
||||||
'user' => $request->user(),
|
'user' => $request->user(),
|
||||||
'isWebinar' => $room->isWebinar(),
|
'isWebinar' => $room->isWebinar(),
|
||||||
]);
|
]);
|
||||||
@@ -130,12 +162,72 @@ class JoinController extends Controller
|
|||||||
$displayName = $reg->display_name;
|
$displayName = $reg->display_name;
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email);
|
$joinStatus = $room->requiresWaitingRoom($user, $role) ? 'waiting' : 'joined';
|
||||||
|
|
||||||
|
$participant = $this->sessions->joinParticipant($session, $user, $role, $displayName, $email, $joinStatus);
|
||||||
|
|
||||||
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
|
app(InvitationService::class)->markAcceptedOnJoin($room, $user, $email);
|
||||||
|
|
||||||
$this->sessions->rememberParticipantSession($request, $participant, $session);
|
$this->sessions->rememberParticipantSession($request, $participant, $session);
|
||||||
|
|
||||||
|
if ($participant->status === 'waiting') {
|
||||||
|
return redirect()->route('meet.join.waiting', $room);
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->route('meet.room', $session);
|
return redirect()->route('meet.room', $session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function waiting(Request $request, Room $room): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$room->loadMissing('organization');
|
||||||
|
|
||||||
|
$session = $room->activeSession();
|
||||||
|
abort_unless($session?->isLive(), 404);
|
||||||
|
|
||||||
|
$participant = $this->participantFromSession($request, $session);
|
||||||
|
|
||||||
|
if (! $participant || $participant->status === 'removed') {
|
||||||
|
return redirect()->route('meet.join', $room);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($participant->status === 'joined') {
|
||||||
|
return redirect()->route('meet.room', $session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('meet.join.waiting', [
|
||||||
|
'room' => $room,
|
||||||
|
'session' => $session,
|
||||||
|
'organization' => $room->organization,
|
||||||
|
'participant' => $participant,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function waitingStatus(Request $request, Room $room): JsonResponse
|
||||||
|
{
|
||||||
|
$session = $room->activeSession();
|
||||||
|
abort_unless($session, 404);
|
||||||
|
|
||||||
|
$participant = $this->participantFromSession($request, $session);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'status' => $participant?->status ?? 'unknown',
|
||||||
|
'redirect' => ($participant && $participant->status === 'joined')
|
||||||
|
? route('meet.room', $session)
|
||||||
|
: null,
|
||||||
|
'denied' => $participant?->status === 'removed',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function participantFromSession(Request $request, Session $session): ?Participant
|
||||||
|
{
|
||||||
|
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||||
|
if (! $participantUuid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Participant::query()
|
||||||
|
->where('uuid', $participantUuid)
|
||||||
|
->where('session_id', $session->id)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Meet;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\Participant;
|
||||||
|
use App\Models\Room;
|
||||||
|
use App\Models\Session;
|
||||||
|
use App\Models\SessionFeedback;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
|
class LeaveController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, Session $session): View|RedirectResponse
|
||||||
|
{
|
||||||
|
$session->loadMissing('room.organization');
|
||||||
|
abort_unless($session->room, 404);
|
||||||
|
|
||||||
|
$feedback = (array) $request->session()->get('meet.left.feedback', []);
|
||||||
|
|
||||||
|
if (($feedback['session_uuid'] ?? null) !== $session->uuid) {
|
||||||
|
return redirect()->route('meet.join', $session->room);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view('meet.left.show', [
|
||||||
|
'session' => $session,
|
||||||
|
'room' => $session->room,
|
||||||
|
'organization' => $session->room->organization,
|
||||||
|
'feedback' => $feedback,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request, Session $session): RedirectResponse
|
||||||
|
{
|
||||||
|
$feedback = (array) $request->session()->get('meet.left.feedback', []);
|
||||||
|
|
||||||
|
abort_unless(($feedback['session_uuid'] ?? null) === $session->uuid, 403);
|
||||||
|
|
||||||
|
$validated = $request->validate([
|
||||||
|
'rating' => ['required', 'integer', 'min:1', 'max:5'],
|
||||||
|
'audio_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
|
||||||
|
'video_rating' => ['nullable', 'integer', 'min:1', 'max:5'],
|
||||||
|
'comment' => ['nullable', 'string', 'max:1000'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
SessionFeedback::updateOrCreate(
|
||||||
|
[
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'participant_uuid' => $feedback['participant_uuid'] ?? null,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'display_name' => $feedback['display_name'] ?? null,
|
||||||
|
'user_ref' => $feedback['user_ref'] ?? null,
|
||||||
|
'rating' => $validated['rating'],
|
||||||
|
'audio_rating' => $validated['audio_rating'] ?? null,
|
||||||
|
'video_rating' => $validated['video_rating'] ?? null,
|
||||||
|
'comment' => $validated['comment'] ?? null,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
$request->session()->forget('meet.left.feedback');
|
||||||
|
|
||||||
|
return redirect()
|
||||||
|
->route('meet.left.thanks', $session)
|
||||||
|
->with('rated', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function thanks(Request $request, Session $session): View
|
||||||
|
{
|
||||||
|
$session->loadMissing('room.organization');
|
||||||
|
|
||||||
|
return view('meet.left.thanks', [
|
||||||
|
'session' => $session,
|
||||||
|
'room' => $session->room,
|
||||||
|
'organization' => $session->room->organization,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,6 +39,12 @@ class MeetingRoomController extends Controller
|
|||||||
->where('session_id', $session->id)
|
->where('session_id', $session->id)
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
|
if ($participant->status === 'waiting') {
|
||||||
|
return redirect()->route('meet.join.waiting', $session->room);
|
||||||
|
}
|
||||||
|
|
||||||
|
abort_unless($participant->status === 'joined', 403, 'You are not in this meeting.');
|
||||||
|
|
||||||
$token = $this->media->isConfigured()
|
$token = $this->media->isConfigured()
|
||||||
? $this->sessions->generateMediaToken($participant)
|
? $this->sessions->generateMediaToken($participant)
|
||||||
: '';
|
: '';
|
||||||
@@ -81,11 +87,18 @@ class MeetingRoomController extends Controller
|
|||||||
|
|
||||||
if ($participant) {
|
if ($participant) {
|
||||||
$this->sessions->leaveParticipant($participant);
|
$this->sessions->leaveParticipant($participant);
|
||||||
|
|
||||||
|
$request->session()->put('meet.left.feedback', [
|
||||||
|
'session_uuid' => $session->uuid,
|
||||||
|
'participant_uuid' => $participant->uuid,
|
||||||
|
'display_name' => $participant->display_name,
|
||||||
|
'user_ref' => $participant->user_ref,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$request->session()->forget("meet.participant.{$session->uuid}");
|
$request->session()->forget("meet.participant.{$session->uuid}");
|
||||||
|
|
||||||
return redirect()->route('meet.dashboard');
|
return redirect()->route('meet.left', $session);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function raiseHand(Request $request, Session $session): JsonResponse
|
public function raiseHand(Request $request, Session $session): JsonResponse
|
||||||
@@ -145,7 +158,7 @@ class MeetingRoomController extends Controller
|
|||||||
{
|
{
|
||||||
$this->currentParticipant($request, $session);
|
$this->currentParticipant($request, $session);
|
||||||
|
|
||||||
$session->load(['participants' => fn ($q) => $q->where('status', 'joined')]);
|
$session->load(['participants' => fn ($q) => $q->whereIn('status', ['joined', 'waiting'])]);
|
||||||
|
|
||||||
$avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref);
|
$avatars = ParticipantPresenter::avatarMap($session->participants, $session->room->host_user_ref);
|
||||||
|
|
||||||
@@ -167,6 +180,7 @@ class MeetingRoomController extends Controller
|
|||||||
'participants' => $session->participants->map(
|
'participants' => $session->participants->map(
|
||||||
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref)
|
fn ($p) => ParticipantPresenter::toArray($p, $avatars, $session->room->host_user_ref)
|
||||||
),
|
),
|
||||||
|
'waiting_count' => $session->participants->where('status', 'waiting')->count(),
|
||||||
'messages' => $messages->map(fn ($m) => [
|
'messages' => $messages->map(fn ($m) => [
|
||||||
'uuid' => $m->uuid,
|
'uuid' => $m->uuid,
|
||||||
'sender_name' => $m->sender_name,
|
'sender_name' => $m->sender_name,
|
||||||
@@ -271,6 +285,28 @@ class MeetingRoomController extends Controller
|
|||||||
return response()->json(['ok' => true]);
|
return response()->json(['ok' => true]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function admit(Request $request, Session $session, Participant $participant): JsonResponse
|
||||||
|
{
|
||||||
|
$current = $this->currentParticipant($request, $session);
|
||||||
|
abort_unless($current->isHost(), 403);
|
||||||
|
abort_unless($participant->session_id === $session->id, 404);
|
||||||
|
|
||||||
|
$this->sessions->admitParticipant($participant);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'status' => 'joined']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deny(Request $request, Session $session, Participant $participant): JsonResponse
|
||||||
|
{
|
||||||
|
$current = $this->currentParticipant($request, $session);
|
||||||
|
abort_unless($current->isHost(), 403);
|
||||||
|
abort_unless($participant->session_id === $session->id, 404);
|
||||||
|
|
||||||
|
$this->sessions->denyParticipant($participant);
|
||||||
|
|
||||||
|
return response()->json(['ok' => true, 'status' => 'removed']);
|
||||||
|
}
|
||||||
|
|
||||||
protected function currentParticipant(Request $request, Session $session): Participant
|
protected function currentParticipant(Request $request, Session $session): Participant
|
||||||
{
|
{
|
||||||
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
$participantUuid = $request->session()->get("meet.participant.{$session->uuid}");
|
||||||
|
|||||||
@@ -111,6 +111,19 @@ class Room extends Model
|
|||||||
return $this->status === 'live';
|
return $this->status === 'live';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function requiresWaitingRoom(?User $user, string $role): bool
|
||||||
|
{
|
||||||
|
if (! $this->setting('waiting_room', true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($role === 'host' || ($user && $user->ownerRef() === $this->host_user_ref)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public function joinUrl(): string
|
public function joinUrl(): string
|
||||||
{
|
{
|
||||||
return rtrim((string) config('app.meet_url', 'https://meet.ladill.com'), '/').'/r/'.$this->uuid;
|
return rtrim((string) config('app.meet_url', 'https://meet.ladill.com'), '/').'/r/'.$this->uuid;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class SessionFeedback extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'meet_session_feedback';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'session_id',
|
||||||
|
'participant_uuid',
|
||||||
|
'display_name',
|
||||||
|
'user_ref',
|
||||||
|
'rating',
|
||||||
|
'audio_rating',
|
||||||
|
'video_rating',
|
||||||
|
'comment',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function session(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Session::class, 'session_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ class ParticipantPresenter
|
|||||||
'uuid' => $participant->uuid,
|
'uuid' => $participant->uuid,
|
||||||
'display_name' => $participant->display_name,
|
'display_name' => $participant->display_name,
|
||||||
'role' => $participant->role,
|
'role' => $participant->role,
|
||||||
|
'status' => $participant->status,
|
||||||
'hand_raised' => $participant->hand_raised,
|
'hand_raised' => $participant->hand_raised,
|
||||||
'is_muted' => $participant->is_muted,
|
'is_muted' => $participant->is_muted,
|
||||||
'is_video_off' => $participant->is_video_off,
|
'is_video_off' => $participant->is_video_off,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ class SessionService
|
|||||||
$session->room->update(['status' => 'ended']);
|
$session->room->update(['status' => 'ended']);
|
||||||
|
|
||||||
$session->participants()
|
$session->participants()
|
||||||
->where('status', 'joined')
|
->whereIn('status', ['joined', 'waiting'])
|
||||||
->update(['status' => 'left', 'left_at' => now()]);
|
->update(['status' => 'left', 'left_at' => now()]);
|
||||||
|
|
||||||
foreach ($session->recordings()->where('status', 'recording')->get() as $recording) {
|
foreach ($session->recordings()->where('status', 'recording')->get() as $recording) {
|
||||||
@@ -89,22 +89,32 @@ class SessionService
|
|||||||
string $role,
|
string $role,
|
||||||
?string $displayName = null,
|
?string $displayName = null,
|
||||||
?string $email = null,
|
?string $email = null,
|
||||||
|
string $status = 'joined',
|
||||||
): Participant {
|
): Participant {
|
||||||
$userRef = $user?->ownerRef();
|
$userRef = $user?->ownerRef();
|
||||||
$existing = $this->findExistingParticipant($session, $userRef, $email);
|
$existing = $this->findExistingParticipant($session, $userRef, $email);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
$existing->update([
|
$updates = [
|
||||||
'status' => 'joined',
|
|
||||||
'joined_at' => now(),
|
|
||||||
'left_at' => null,
|
'left_at' => null,
|
||||||
'display_name' => $displayName ?? $existing->display_name,
|
'display_name' => $displayName ?? $existing->display_name,
|
||||||
'ip_address' => request()?->ip(),
|
'ip_address' => request()?->ip(),
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
if ($existing->status === 'waiting') {
|
||||||
|
$updates['status'] = 'waiting';
|
||||||
|
} else {
|
||||||
|
$updates['status'] = 'joined';
|
||||||
|
$updates['joined_at'] = $existing->joined_at ?? now();
|
||||||
|
}
|
||||||
|
|
||||||
|
$existing->update($updates);
|
||||||
|
|
||||||
return $existing;
|
return $existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$joinedAt = $status === 'joined' ? now() : null;
|
||||||
|
|
||||||
$participant = Participant::create([
|
$participant = Participant::create([
|
||||||
'owner_ref' => $session->owner_ref,
|
'owner_ref' => $session->owner_ref,
|
||||||
'session_id' => $session->id,
|
'session_id' => $session->id,
|
||||||
@@ -112,23 +122,72 @@ class SessionService
|
|||||||
'display_name' => $displayName ?? $user?->name ?? 'Guest',
|
'display_name' => $displayName ?? $user?->name ?? 'Guest',
|
||||||
'email' => $email,
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
'status' => 'joined',
|
'status' => $status,
|
||||||
'joined_at' => now(),
|
'joined_at' => $joinedAt,
|
||||||
'ip_address' => request()?->ip(),
|
'ip_address' => request()?->ip(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->updatePeakParticipants($session);
|
if ($status === 'joined') {
|
||||||
|
$this->updatePeakParticipants($session);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === 'joined') {
|
||||||
|
AuditLogger::record(
|
||||||
|
$session->owner_ref,
|
||||||
|
'participant.joined',
|
||||||
|
$session->room->organization_id,
|
||||||
|
$userRef,
|
||||||
|
Participant::class,
|
||||||
|
$participant->id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $participant;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function admitParticipant(Participant $participant): Participant
|
||||||
|
{
|
||||||
|
abort_unless($participant->status === 'waiting', 422);
|
||||||
|
|
||||||
|
$participant->update([
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
'left_at' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->updatePeakParticipants($participant->session);
|
||||||
|
|
||||||
AuditLogger::record(
|
AuditLogger::record(
|
||||||
$session->owner_ref,
|
$participant->owner_ref,
|
||||||
'participant.joined',
|
'participant.joined',
|
||||||
$session->room->organization_id,
|
$participant->session->room->organization_id,
|
||||||
$userRef,
|
$participant->user_ref,
|
||||||
Participant::class,
|
Participant::class,
|
||||||
$participant->id,
|
$participant->id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $participant;
|
return $participant->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function denyParticipant(Participant $participant): Participant
|
||||||
|
{
|
||||||
|
abort_unless($participant->status === 'waiting', 422);
|
||||||
|
|
||||||
|
$participant->update([
|
||||||
|
'status' => 'removed',
|
||||||
|
'left_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
AuditLogger::record(
|
||||||
|
$participant->owner_ref,
|
||||||
|
'participant.removed',
|
||||||
|
$participant->session->room->organization_id,
|
||||||
|
$participant->user_ref,
|
||||||
|
Participant::class,
|
||||||
|
$participant->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $participant->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findExistingParticipant(Session $session, ?string $userRef, ?string $email): ?Participant
|
protected function findExistingParticipant(Session $session, ?string $userRef, ?string $email): ?Participant
|
||||||
|
|||||||
@@ -51,6 +51,9 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
\Illuminate\Support\Facades\Route::bind('registration', function (string $value) {
|
\Illuminate\Support\Facades\Route::bind('registration', function (string $value) {
|
||||||
return \App\Models\WebinarRegistration::where('uuid', $value)->firstOrFail();
|
return \App\Models\WebinarRegistration::where('uuid', $value)->firstOrFail();
|
||||||
});
|
});
|
||||||
|
\Illuminate\Support\Facades\Route::bind('participant', function (string $value) {
|
||||||
|
return \App\Models\Participant::where('uuid', $value)->firstOrFail();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('meet_session_feedback', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('session_id')->constrained('meet_sessions')->cascadeOnDelete();
|
||||||
|
$table->uuid('participant_uuid')->nullable()->index();
|
||||||
|
$table->string('display_name')->nullable();
|
||||||
|
$table->string('user_ref')->nullable()->index();
|
||||||
|
$table->unsignedTinyInteger('rating');
|
||||||
|
$table->unsignedTinyInteger('audio_rating')->nullable();
|
||||||
|
$table->unsignedTinyInteger('video_rating')->nullable();
|
||||||
|
$table->text('comment')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique(['session_id', 'participant_uuid']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('meet_session_feedback');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import Alpine from 'alpinejs';
|
||||||
|
|
||||||
|
Alpine.data('waitingRoom', () => ({
|
||||||
|
message: 'Waiting for the host to let you in…',
|
||||||
|
denied: false,
|
||||||
|
pollTimer: null,
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.pollTimer = setInterval(() => this.poll(), 2500);
|
||||||
|
this.poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.pollTimer) {
|
||||||
|
clearInterval(this.pollTimer);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async poll() {
|
||||||
|
const url = this.$el.dataset.statusUrl;
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (data.redirect) {
|
||||||
|
window.location.href = data.redirect;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.denied) {
|
||||||
|
this.denied = true;
|
||||||
|
this.message = 'The host declined your request to join.';
|
||||||
|
clearInterval(this.pollTimer);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore transient errors
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
Alpine.data('leaveFeedback', () => ({
|
||||||
|
rating: 0,
|
||||||
|
audioRating: 0,
|
||||||
|
videoRating: 0,
|
||||||
|
hoverRating: 0,
|
||||||
|
hoverAudio: 0,
|
||||||
|
hoverVideo: 0,
|
||||||
|
comment: '',
|
||||||
|
|
||||||
|
setRating(value) {
|
||||||
|
this.rating = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setAudioRating(value) {
|
||||||
|
this.audioRating = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
setVideoRating(value) {
|
||||||
|
this.videoRating = value;
|
||||||
|
},
|
||||||
|
|
||||||
|
canSubmit() {
|
||||||
|
return this.rating > 0;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.Alpine = Alpine;
|
||||||
|
Alpine.start();
|
||||||
@@ -85,6 +85,7 @@ function meetRoom() {
|
|||||||
whiteboardUrl: el.dataset.whiteboardUrl,
|
whiteboardUrl: el.dataset.whiteboardUrl,
|
||||||
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
whiteboardSaveUrl: el.dataset.whiteboardSaveUrl,
|
||||||
csrf: el.dataset.csrf,
|
csrf: el.dataset.csrf,
|
||||||
|
sessionUuid: el.dataset.sessionUuid,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.joinUrl = el.dataset.joinUrl || '';
|
this.joinUrl = el.dataset.joinUrl || '';
|
||||||
@@ -334,7 +335,7 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
hostParticipant() {
|
hostParticipant() {
|
||||||
const hosts = this.sessionParticipants.filter((p) => p.role === 'host' || p.role === 'co_host');
|
const hosts = this.inCallParticipants().filter((p) => p.role === 'host' || p.role === 'co_host');
|
||||||
if (hosts.length) {
|
if (hosts.length) {
|
||||||
return hosts[0];
|
return hosts[0];
|
||||||
}
|
}
|
||||||
@@ -352,13 +353,21 @@ function meetRoom() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
participantCount() {
|
participantCount() {
|
||||||
return Math.max(this.sessionParticipants.length, this.participants.length);
|
return this.inCallParticipants().length;
|
||||||
|
},
|
||||||
|
|
||||||
|
inCallParticipants() {
|
||||||
|
return this.sessionParticipants.filter((p) => p.status === 'joined');
|
||||||
|
},
|
||||||
|
|
||||||
|
waitingParticipants() {
|
||||||
|
return this.sessionParticipants.filter((p) => p.status === 'waiting');
|
||||||
},
|
},
|
||||||
|
|
||||||
sortedParticipants() {
|
sortedParticipants() {
|
||||||
const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 };
|
const order = { host: 0, co_host: 1, panelist: 2, participant: 3, guest: 4, attendee: 5 };
|
||||||
|
|
||||||
return [...this.sessionParticipants].sort((a, b) => {
|
return [...this.inCallParticipants()].sort((a, b) => {
|
||||||
const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
|
const diff = (order[a.role] ?? 9) - (order[b.role] ?? 9);
|
||||||
|
|
||||||
return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name);
|
return diff !== 0 ? diff : a.display_name.localeCompare(b.display_name);
|
||||||
@@ -378,6 +387,26 @@ function meetRoom() {
|
|||||||
return labels[role] || 'Participant';
|
return labels[role] || 'Participant';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async admitParticipant(uuid) {
|
||||||
|
if (!this.isHost || !this.config.sessionUuid) return;
|
||||||
|
|
||||||
|
await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/admit`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
await this.poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
async denyParticipant(uuid) {
|
||||||
|
if (!this.isHost || !this.config.sessionUuid) return;
|
||||||
|
|
||||||
|
await fetch(`/room/${this.config.sessionUuid}/participants/${uuid}/deny`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: this.headers(),
|
||||||
|
});
|
||||||
|
await this.poll();
|
||||||
|
},
|
||||||
|
|
||||||
getOrCreateParticipantTile(participant) {
|
getOrCreateParticipantTile(participant) {
|
||||||
const grid = document.getElementById('video-grid');
|
const grid = document.getElementById('video-grid');
|
||||||
if (!grid) return null;
|
if (!grid) return null;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
@props([
|
||||||
|
'title' => 'Ladill Meet',
|
||||||
|
'organization' => null,
|
||||||
|
'eyebrow' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||||
|
<title>{{ $title }}</title>
|
||||||
|
@include('partials.favicon')
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=figtree:400,500,600,700&display=swap" rel="stylesheet" />
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/meet-public.js'])
|
||||||
|
@stack('head')
|
||||||
|
</head>
|
||||||
|
<body {{ $attributes->merge(['class' => 'min-h-screen bg-slate-100 font-sans text-slate-900 antialiased']) }}>
|
||||||
|
<div class="pointer-events-none fixed inset-0 overflow-hidden" aria-hidden="true">
|
||||||
|
<div class="absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-200/40 blur-3xl"></div>
|
||||||
|
<div class="absolute -right-16 bottom-0 h-80 w-80 rounded-full bg-violet-200/40 blur-3xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fixed inset-x-0 top-0 z-10 h-1.5 bg-gradient-to-r from-indigo-600 to-violet-600"></div>
|
||||||
|
|
||||||
|
@if ($organization)
|
||||||
|
@include('meet.partials.kiosk-brand', ['organization' => $organization])
|
||||||
|
@else
|
||||||
|
<div class="fixed left-0 top-0 z-20 p-6">
|
||||||
|
<img src="{{ asset(\App\Support\OrganizationBranding::DEFAULT_LOGO) }}?v={{ @filemtime(public_path(\App\Support\OrganizationBranding::DEFAULT_LOGO)) ?: '1' }}"
|
||||||
|
alt="Ladill Meet"
|
||||||
|
class="h-8 w-auto max-w-[220px] object-contain object-left">
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="relative mx-auto flex min-h-screen max-w-3xl flex-col pt-20 px-6 pb-12">
|
||||||
|
@if ($eyebrow)
|
||||||
|
<p class="text-center text-sm font-semibold uppercase tracking-wider text-indigo-600">{{ $eyebrow }}</p>
|
||||||
|
@endif
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,16 +1,8 @@
|
|||||||
<!DOCTYPE html>
|
<x-meet-kiosk-layout :title="'Meeting cancelled'" :organization="$organization ?? null" eyebrow="Ladill Meet">
|
||||||
<html lang="en" class="h-full">
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||||
<head>
|
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
<meta charset="utf-8">
|
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<p class="mt-3 text-sm text-slate-600">This meeting has been cancelled by the host.</p>
|
||||||
<title>Meeting cancelled · Ladill Meet</title>
|
</div>
|
||||||
@include('partials.favicon')
|
|
||||||
@vite(['resources/css/app.css'])
|
|
||||||
</head>
|
|
||||||
<body class="flex min-h-screen items-center justify-center bg-slate-100 p-4 font-sans">
|
|
||||||
<div class="rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
|
||||||
<h1 class="text-xl font-semibold text-slate-900">Meeting cancelled</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ $room->title }} is no longer available.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</x-meet-kiosk-layout>
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<x-meet-kiosk-layout :title="'Access denied · '.$room->title" :organization="$organization" eyebrow="Ladill Meet">
|
||||||
<html lang="en" class="h-full">
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||||
<head>
|
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
<meta charset="utf-8">
|
<div class="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-amber-50 text-amber-600">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||||
<title>Access denied · Ladill Meet</title>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
|
||||||
@include('partials.favicon')
|
</svg>
|
||||||
@vite(['resources/css/app.css'])
|
</div>
|
||||||
</head>
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">Unable to join</h1>
|
||||||
<body class="flex min-h-screen items-center justify-center bg-slate-100 p-4 font-sans">
|
<p class="mt-3 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this meeting.' }}</p>
|
||||||
<div class="max-w-md rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
<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">
|
||||||
<h1 class="text-xl font-semibold text-slate-900">Unable to join</h1>
|
Sign in with Ladill
|
||||||
<p class="mt-2 text-sm text-slate-600">{{ $reason ?? 'You do not have access to this meeting.' }}</p>
|
</a>
|
||||||
<a href="{{ route('sso.connect', ['redirect' => route('meet.join', $room), 'interactive' => 1]) }}" class="btn-primary mt-6 inline-block">Sign in</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</x-meet-kiosk-layout>
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,27 +1,24 @@
|
|||||||
<!DOCTYPE html>
|
<x-meet-kiosk-layout :title="'Passcode · '.$room->title" :organization="$organization" eyebrow="Secure meeting">
|
||||||
<html lang="en" class="h-full">
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center">
|
||||||
<head>
|
<div class="w-full max-w-md rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
<meta charset="utf-8">
|
<h1 class="text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<p class="mt-2 text-sm text-slate-500">This meeting is protected. Enter the passcode to continue.</p>
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
<title>Join · {{ $room->title }} · Ladill Meet</title>
|
|
||||||
@include('partials.favicon')
|
|
||||||
@vite(['resources/css/app.css'])
|
|
||||||
</head>
|
|
||||||
<body class="flex min-h-screen items-center justify-center bg-slate-900 p-4 font-sans text-white">
|
|
||||||
<div class="w-full max-w-md rounded-2xl bg-slate-800 p-8 shadow-xl">
|
|
||||||
<h1 class="text-xl font-semibold">{{ $room->title }}</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-400">Enter the meeting passcode to continue.</p>
|
|
||||||
|
|
||||||
<form method="POST" action="{{ route('meet.join.passcode', $room) }}" class="mt-6 space-y-4">
|
@if ($errors->any())
|
||||||
@csrf
|
<div class="mt-4 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
<input type="password" name="passcode" required autofocus placeholder="Passcode"
|
{{ $errors->first() }}
|
||||||
class="w-full rounded-lg border-slate-600 bg-slate-700 px-4 py-3 text-white placeholder:text-slate-400">
|
</div>
|
||||||
@error('passcode')
|
@endif
|
||||||
<p class="text-sm text-red-400">{{ $message }}</p>
|
|
||||||
@enderror
|
<form method="POST" action="{{ route('meet.join.passcode', $room) }}" class="mt-6 space-y-4">
|
||||||
<button type="submit" class="btn-primary w-full">Continue</button>
|
@csrf
|
||||||
</form>
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Passcode</label>
|
||||||
|
<input type="password" name="passcode" required autofocus
|
||||||
|
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm ring-1 ring-slate-200 focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-primary btn-primary-lg w-full py-3.5 font-semibold">Continue</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</x-meet-kiosk-layout>
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -1,46 +1,66 @@
|
|||||||
<!DOCTYPE html>
|
<x-meet-kiosk-layout :title="'Join · '.$room->title" :organization="$organization" eyebrow="Ladill Meet">
|
||||||
<html lang="en" class="h-full">
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center">
|
||||||
<head>
|
<div class="w-full max-w-lg">
|
||||||
<meta charset="utf-8">
|
<h1 class="text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl">{{ $room->title }}</h1>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
||||||
<title>Join · {{ $room->title }} · Ladill Meet</title>
|
|
||||||
@include('partials.favicon')
|
|
||||||
@vite(['resources/css/app.css'])
|
|
||||||
</head>
|
|
||||||
<body class="flex min-h-screen items-center justify-center bg-slate-900 p-4 font-sans text-white">
|
|
||||||
<div class="w-full max-w-md rounded-2xl bg-slate-800 p-8 shadow-xl">
|
|
||||||
<h1 class="text-xl font-semibold">{{ $room->title }}</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-400">Ready to join?</p>
|
|
||||||
|
|
||||||
@if ($errors->any())
|
@if ($room->scheduled_at)
|
||||||
<div class="mt-4 rounded-lg border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-200">
|
<p class="mt-3 text-base text-slate-500">
|
||||||
{{ $errors->first() }}
|
{{ $room->scheduled_at->timezone($room->timezone)->format('l, j F Y · g:i A T') }}
|
||||||
</div>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<form method="POST" action="{{ route('meet.join.enter', $room) }}" class="mt-6 space-y-4">
|
@if ($room->host_user_ref && ($host = \App\Models\User::query()->where('public_id', $room->host_user_ref)->first()))
|
||||||
@csrf
|
<p class="mt-2 text-sm text-slate-500">Hosted by <span class="font-medium text-slate-700">{{ $host->name }}</span></p>
|
||||||
@guest
|
@endif
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-slate-300">Your name</label>
|
@if ($room->description)
|
||||||
<input type="text" name="display_name" required placeholder="Display name"
|
<p class="mt-4 text-sm leading-relaxed text-slate-600">{{ $room->description }}</p>
|
||||||
class="mt-1 w-full rounded-lg border-slate-600 bg-slate-700 px-4 py-3 text-white placeholder:text-slate-400">
|
@endif
|
||||||
|
|
||||||
|
@if ($errors->any())
|
||||||
|
<div class="mt-6 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-left text-sm text-red-700">
|
||||||
|
{{ $errors->first() }}
|
||||||
</div>
|
</div>
|
||||||
@if ($isWebinar ?? false)
|
@endif
|
||||||
<div>
|
|
||||||
<label class="block text-sm text-slate-300">Email</label>
|
|
||||||
<input type="email" name="email" required placeholder="you@example.com"
|
|
||||||
class="mt-1 w-full rounded-lg border-slate-600 bg-slate-700 px-4 py-3 text-white placeholder:text-slate-400">
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endguest
|
|
||||||
<button type="submit" class="btn-primary w-full">Join meeting</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
@auth
|
<form method="POST" action="{{ route('meet.join.enter', $room) }}" class="mt-10 text-left">
|
||||||
<p class="mt-4 text-center text-xs text-slate-500">Joining as {{ auth()->user()->name }}</p>
|
@csrf
|
||||||
@endauth
|
|
||||||
|
@guest
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Your name</label>
|
||||||
|
<input type="text" name="display_name" required placeholder="How should we show your name?"
|
||||||
|
value="{{ old('display_name') }}"
|
||||||
|
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
|
||||||
|
@if ($isWebinar ?? false)
|
||||||
|
<label class="mt-4 block text-sm font-medium text-slate-700">Email</label>
|
||||||
|
<input type="email" name="email" required placeholder="you@example.com"
|
||||||
|
value="{{ old('email') }}"
|
||||||
|
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3.5 text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500">
|
||||||
|
@endif
|
||||||
|
@endguest
|
||||||
|
|
||||||
|
@auth
|
||||||
|
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600 shadow-sm">
|
||||||
|
Joining as <span class="font-semibold text-slate-900">{{ auth()->user()->name }}</span>
|
||||||
|
</div>
|
||||||
|
@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>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<button type="submit" class="btn-primary btn-primary-lg mt-8 w-full py-4 text-base font-semibold">
|
||||||
|
Join meeting
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
@guest
|
||||||
|
<p class="mt-6 text-center text-sm text-slate-500">
|
||||||
|
Have a Ladill account?
|
||||||
|
<a href="{{ route('sso.connect', ['redirect' => route('meet.join', $room), 'interactive' => 1]) }}" class="font-medium text-indigo-600 hover:text-indigo-700">Sign in</a>
|
||||||
|
</p>
|
||||||
|
@endguest
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</x-meet-kiosk-layout>
|
||||||
</html>
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<x-meet-kiosk-layout :title="'Waiting · '.$room->title" :organization="$organization" eyebrow="Waiting room">
|
||||||
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center text-center"
|
||||||
|
x-data="waitingRoom"
|
||||||
|
x-init="init()"
|
||||||
|
data-status-url="{{ route('meet.join.waiting.status', $room) }}">
|
||||||
|
<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-16 w-16 items-center justify-center rounded-full bg-indigo-50 text-indigo-600" x-show="!denied">
|
||||||
|
<svg class="h-8 w-8 animate-pulse" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6v6h4.5m4.5 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-red-50 text-red-600" x-show="denied" x-cloak>
|
||||||
|
<svg class="h-8 w-8" 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">{{ $room->title }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">Hi {{ $participant->display_name }}, please wait.</p>
|
||||||
|
<p class="mt-4 text-base text-slate-700" x-text="message"></p>
|
||||||
|
|
||||||
|
<p class="mt-6 text-xs text-slate-400" x-show="!denied">You will join automatically once the host admits you.</p>
|
||||||
|
|
||||||
|
<a href="{{ route('meet.join', $room) }}" class="mt-8 inline-block text-sm font-medium text-indigo-600 hover:text-indigo-700" x-show="denied" x-cloak>
|
||||||
|
Back to join page
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-meet-kiosk-layout>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<x-meet-kiosk-layout :title="'You left · '.$room->title" :organization="$organization" eyebrow="Meeting ended">
|
||||||
|
<div class="flex min-h-[calc(100vh-8rem)] flex-col items-center justify-center">
|
||||||
|
<form method="POST" action="{{ route('meet.left.feedback', $session) }}"
|
||||||
|
x-data="leaveFeedback"
|
||||||
|
class="w-full max-w-lg rounded-3xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
@csrf
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-2xl font-bold text-slate-900">You left the 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>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<p class="text-sm font-medium text-slate-700">How was the meeting overall?</p>
|
||||||
|
<div class="mt-3 flex justify-center gap-2">
|
||||||
|
@foreach (range(1, 5) as $star)
|
||||||
|
<button type="button"
|
||||||
|
@click="setRating({{ $star }})"
|
||||||
|
@mouseenter="hoverRating = {{ $star }}"
|
||||||
|
@mouseleave="hoverRating = 0"
|
||||||
|
class="rounded-lg p-1 transition-transform hover:scale-110"
|
||||||
|
aria-label="Rate {{ $star }} stars">
|
||||||
|
<svg class="h-9 w-9"
|
||||||
|
:class="(hoverRating || rating) >= {{ $star }} ? 'text-amber-400' : 'text-slate-300'"
|
||||||
|
fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="rating" :value="rating" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-slate-700">Audio quality</p>
|
||||||
|
<div class="mt-2 flex gap-1">
|
||||||
|
@foreach (range(1, 5) as $star)
|
||||||
|
<button type="button" @click="setAudioRating({{ $star }})"
|
||||||
|
class="rounded p-0.5">
|
||||||
|
<svg class="h-6 w-6"
|
||||||
|
:class="(hoverAudio || audioRating) >= {{ $star }} ? 'text-amber-400' : 'text-slate-300'"
|
||||||
|
fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="audio_rating" :value="audioRating || ''">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-slate-700">Video quality</p>
|
||||||
|
<div class="mt-2 flex gap-1">
|
||||||
|
@foreach (range(1, 5) as $star)
|
||||||
|
<button type="button" @click="setVideoRating({{ $star }})"
|
||||||
|
class="rounded p-0.5">
|
||||||
|
<svg class="h-6 w-6"
|
||||||
|
:class="(hoverVideo || videoRating) >= {{ $star }} ? 'text-amber-400' : 'text-slate-300'"
|
||||||
|
fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87L18.18 22 12 18.56 5.82 22 7 14.14l-5-4.87 6.91-1.01L12 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="video_rating" :value="videoRating || ''">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<label class="block text-sm font-medium text-slate-700">Anything else we should know? <span class="font-normal text-slate-400">(optional)</span></label>
|
||||||
|
<textarea name="comment" rows="3" x-model="comment" placeholder="Share feedback for the host or Ladill Meet…"
|
||||||
|
class="mt-2 w-full rounded-2xl border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 shadow-sm ring-1 ring-slate-200 placeholder:text-slate-400 focus:border-indigo-500 focus:ring-indigo-500"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col gap-3 sm:flex-row">
|
||||||
|
<button type="submit"
|
||||||
|
class="btn-primary btn-primary-lg flex-1 py-3.5 font-semibold disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
:disabled="!canSubmit()">
|
||||||
|
Submit feedback
|
||||||
|
</button>
|
||||||
|
<a href="{{ route('meet.left.thanks', $session) }}"
|
||||||
|
class="inline-flex flex-1 items-center justify-center rounded-2xl border border-slate-200 bg-white px-4 py-3.5 text-sm font-medium text-slate-600 hover:bg-slate-50">
|
||||||
|
Skip
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</x-meet-kiosk-layout>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<x-meet-kiosk-layout :title="'Thank you'" :organization="$organization" eyebrow="Ladill Meet">
|
||||||
|
<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-emerald-50 text-emerald-600">
|
||||||
|
<svg class="h-7 w-7" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">
|
||||||
|
@if (session('rated'))
|
||||||
|
Thanks for your feedback
|
||||||
|
@else
|
||||||
|
You have left the meeting
|
||||||
|
@endif
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 text-sm text-slate-600">{{ $room->title }}</p>
|
||||||
|
|
||||||
|
<div class="mt-8 flex flex-col gap-3">
|
||||||
|
@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>
|
||||||
|
@endauth
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</x-meet-kiosk-layout>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@php
|
||||||
|
use App\Support\OrganizationBranding;
|
||||||
|
@endphp
|
||||||
|
<div class="fixed left-0 top-0 z-20 p-6">
|
||||||
|
<img src="{{ OrganizationBranding::logoUrl($organization) }}"
|
||||||
|
alt="{{ OrganizationBranding::logoAlt($organization) }}"
|
||||||
|
class="h-8 w-auto max-w-[220px] object-contain object-left">
|
||||||
|
</div>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\Meet\ParticipantPresenter;
|
use App\Services\Meet\ParticipantPresenter;
|
||||||
|
|
||||||
$joined = $session->participants->where('status', 'joined');
|
$joined = $session->participants->whereIn('status', ['joined', 'waiting']);
|
||||||
$participantAvatars = ParticipantPresenter::avatarMap($joined, $room->host_user_ref);
|
$participantAvatars = ParticipantPresenter::avatarMap($joined, $room->host_user_ref);
|
||||||
$joinedParticipants = $joined
|
$joinedParticipants = $joined
|
||||||
->map(fn ($p) => ParticipantPresenter::toArray($p, $participantAvatars, $room->host_user_ref))
|
->map(fn ($p) => ParticipantPresenter::toArray($p, $participantAvatars, $room->host_user_ref))
|
||||||
@@ -61,6 +61,7 @@
|
|||||||
data-room-title="{{ $room->title }}"
|
data-room-title="{{ $room->title }}"
|
||||||
data-passcode="{{ $room->passcode ?? '' }}"
|
data-passcode="{{ $room->passcode ?? '' }}"
|
||||||
data-csrf="{{ csrf_token() }}"
|
data-csrf="{{ csrf_token() }}"
|
||||||
|
data-session-uuid="{{ $session->uuid }}"
|
||||||
data-initial-participants='@json($joinedParticipants)'
|
data-initial-participants='@json($joinedParticipants)'
|
||||||
data-room-host='@json($roomHost)'
|
data-room-host='@json($roomHost)'
|
||||||
hidden></div>
|
hidden></div>
|
||||||
@@ -384,7 +385,28 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto rounded-2xl bg-slate-900/90 p-2">
|
<div class="flex min-h-0 flex-1 flex-col overflow-y-auto rounded-2xl bg-slate-900/90 p-2">
|
||||||
<template x-for="person in sortedParticipants()" :key="person.uuid">
|
<template x-if="isHost && waitingParticipants().length">
|
||||||
|
<div class="mb-3 border-b border-slate-800 pb-3">
|
||||||
|
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-amber-300">Waiting to join</p>
|
||||||
|
<template x-for="person in waitingParticipants()" :key="'wait-'+person.uuid">
|
||||||
|
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
|
||||||
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-amber-500/20 text-sm font-semibold text-amber-200"
|
||||||
|
x-text="participantInitials(person.display_name)"></span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block truncate text-sm font-medium text-white" x-text="person.display_name"></span>
|
||||||
|
<span class="block text-xs text-amber-200/80">Waiting for admission</span>
|
||||||
|
</span>
|
||||||
|
<span class="flex shrink-0 gap-1">
|
||||||
|
<button type="button" @click="admitParticipant(person.uuid)"
|
||||||
|
class="rounded-lg bg-emerald-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-emerald-500">Admit</button>
|
||||||
|
<button type="button" @click="denyParticipant(person.uuid)"
|
||||||
|
class="rounded-lg bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-200 hover:bg-slate-600">Deny</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-for="person in inCallParticipants()" :key="person.uuid">
|
||||||
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
|
<div class="flex items-center gap-3 rounded-xl px-2 py-2.5">
|
||||||
<span class="relative flex h-10 w-10 shrink-0">
|
<span class="relative flex h-10 w-10 shrink-0">
|
||||||
<img x-show="person.avatar_url"
|
<img x-show="person.avatar_url"
|
||||||
@@ -413,7 +435,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<p x-show="sessionParticipants.length === 0" class="px-2 py-4 text-center text-xs text-slate-500">No participants yet.</p>
|
<p x-show="inCallParticipants().length === 0 && waitingParticipants().length === 0" class="px-2 py-4 text-center text-xs text-slate-500">No participants yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -87,7 +87,13 @@
|
|||||||
|
|
||||||
<fieldset class="space-y-2">
|
<fieldset class="space-y-2">
|
||||||
<legend class="text-sm font-medium text-slate-700">Options</legend>
|
<legend class="text-sm font-medium text-slate-700">Options</legend>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', $defaultSettings['waiting_room'] ?? true)) class="rounded border-slate-300"> Waiting room</label>
|
<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">Guests wait until the host admits them. Uncheck to let guests join directly.</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="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 participants on join</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 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="auto_record" value="1" @checked(old('auto_record')) class="rounded border-slate-300"> Auto-record</label>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<input type="number" name="duration_minutes" value="60" min="15" max="480" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
<input type="number" name="duration_minutes" value="60" min="15" max="480" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||||
</div>
|
</div>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_default" value="1" class="rounded border-slate-300"> Default template</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="is_default" value="1" class="rounded border-slate-300"> Default template</label>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" checked class="rounded border-slate-300"> Waiting room</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="waiting_room" value="1" checked class="rounded border-slate-300"> Waiting room (host admits guests)</label>
|
||||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" class="rounded border-slate-300"> Auto-record</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" 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" class="rounded border-slate-300"> Live captions</label>
|
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="live_captions" value="1" class="rounded border-slate-300"> Live captions</label>
|
||||||
<button type="submit" class="btn-primary w-full">Save template</button>
|
<button type="submit" class="btn-primary w-full">Save template</button>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Http\Controllers\Meet\DashboardController;
|
|||||||
use App\Http\Controllers\Meet\DirectMessageController;
|
use App\Http\Controllers\Meet\DirectMessageController;
|
||||||
use App\Http\Controllers\Meet\InvitationController;
|
use App\Http\Controllers\Meet\InvitationController;
|
||||||
use App\Http\Controllers\Meet\JoinController;
|
use App\Http\Controllers\Meet\JoinController;
|
||||||
|
use App\Http\Controllers\Meet\LeaveController;
|
||||||
use App\Http\Controllers\Meet\MeetingFeaturesController;
|
use App\Http\Controllers\Meet\MeetingFeaturesController;
|
||||||
use App\Http\Controllers\Meet\MeetingRoomController;
|
use App\Http\Controllers\Meet\MeetingRoomController;
|
||||||
use App\Http\Controllers\Meet\MemberController;
|
use App\Http\Controllers\Meet\MemberController;
|
||||||
@@ -40,9 +41,15 @@ Route::get('/sso/platform-signed-out', [SsoLoginController::class, 'platformSign
|
|||||||
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('meet.dashboard') : view('auth.signed-out'))->name('meet.signed-out');
|
Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('meet.dashboard') : view('auth.signed-out'))->name('meet.signed-out');
|
||||||
|
|
||||||
Route::get('/r/{room}', [JoinController::class, 'show'])->name('meet.join');
|
Route::get('/r/{room}', [JoinController::class, 'show'])->name('meet.join');
|
||||||
|
Route::get('/r/{room}/waiting', [JoinController::class, 'waiting'])->name('meet.join.waiting');
|
||||||
|
Route::get('/r/{room}/waiting/status', [JoinController::class, 'waitingStatus'])->name('meet.join.waiting.status');
|
||||||
Route::post('/r/{room}/passcode', [JoinController::class, 'verifyPasscode'])->name('meet.join.passcode');
|
Route::post('/r/{room}/passcode', [JoinController::class, 'verifyPasscode'])->name('meet.join.passcode');
|
||||||
Route::post('/r/{room}/enter', [JoinController::class, 'enter'])->name('meet.join.enter');
|
Route::post('/r/{room}/enter', [JoinController::class, 'enter'])->name('meet.join.enter');
|
||||||
|
|
||||||
|
Route::get('/room/{session}/left', [LeaveController::class, 'show'])->name('meet.left');
|
||||||
|
Route::post('/room/{session}/left', [LeaveController::class, 'store'])->name('meet.left.feedback');
|
||||||
|
Route::get('/room/{session}/thanks', [LeaveController::class, 'thanks'])->name('meet.left.thanks');
|
||||||
|
|
||||||
Route::match(['get', 'post'], '/invite/{invitation}/rsvp', [InvitationController::class, 'rsvp'])->name('meet.invitations.rsvp');
|
Route::match(['get', 'post'], '/invite/{invitation}/rsvp', [InvitationController::class, 'rsvp'])->name('meet.invitations.rsvp');
|
||||||
|
|
||||||
Route::get('/w/{room}/register', [WebinarRegistrationController::class, 'show'])->name('meet.webinar.register');
|
Route::get('/w/{room}/register', [WebinarRegistrationController::class, 'show'])->name('meet.webinar.register');
|
||||||
@@ -51,6 +58,8 @@ Route::post('/w/{room}/register', [WebinarRegistrationController::class, 'store'
|
|||||||
Route::get('/room/{session}', [MeetingRoomController::class, 'show'])->name('meet.room');
|
Route::get('/room/{session}', [MeetingRoomController::class, 'show'])->name('meet.room');
|
||||||
Route::post('/room/{session}/end', [MeetingRoomController::class, 'end'])->name('meet.room.end');
|
Route::post('/room/{session}/end', [MeetingRoomController::class, 'end'])->name('meet.room.end');
|
||||||
Route::post('/room/{session}/leave', [MeetingRoomController::class, 'leave'])->name('meet.room.leave');
|
Route::post('/room/{session}/leave', [MeetingRoomController::class, 'leave'])->name('meet.room.leave');
|
||||||
|
Route::post('/room/{session}/participants/{participant}/admit', [MeetingRoomController::class, 'admit'])->name('meet.room.admit');
|
||||||
|
Route::post('/room/{session}/participants/{participant}/deny', [MeetingRoomController::class, 'deny'])->name('meet.room.deny');
|
||||||
Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand');
|
Route::post('/room/{session}/raise-hand', [MeetingRoomController::class, 'raiseHand'])->name('meet.room.raise-hand');
|
||||||
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
|
Route::post('/room/{session}/chat', [MeetingRoomController::class, 'sendMessage'])->name('meet.room.chat');
|
||||||
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
|
Route::post('/room/{session}/reaction', [MeetingRoomController::class, 'sendReaction'])->name('meet.room.reaction');
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ class MeetWebTest extends TestCase
|
|||||||
$room = $this->createRoom([
|
$room = $this->createRoom([
|
||||||
'settings' => array_merge(config('meet.default_settings'), [
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
'join_before_host' => true,
|
'join_before_host' => true,
|
||||||
|
'waiting_room' => false,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
$session = Session::create([
|
$session = Session::create([
|
||||||
@@ -157,6 +158,7 @@ class MeetWebTest extends TestCase
|
|||||||
$room = $this->createRoom([
|
$room = $this->createRoom([
|
||||||
'settings' => array_merge(config('meet.default_settings'), [
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
'join_before_host' => true,
|
'join_before_host' => true,
|
||||||
|
'waiting_room' => false,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
$session = Session::create([
|
$session = Session::create([
|
||||||
@@ -196,6 +198,59 @@ class MeetWebTest extends TestCase
|
|||||||
$this->assertNotSame($hostParticipant->uuid, $guest->uuid);
|
$this->assertNotSame($hostParticipant->uuid, $guest->uuid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_guest_is_placed_in_waiting_room_when_enabled(): void
|
||||||
|
{
|
||||||
|
$room = $this->createRoom([
|
||||||
|
'settings' => array_merge(config('meet.default_settings'), [
|
||||||
|
'join_before_host' => true,
|
||||||
|
'waiting_room' => true,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'room-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
$room->update(['status' => 'live']);
|
||||||
|
|
||||||
|
$this->post('/r/'.$room->uuid.'/enter', [
|
||||||
|
'display_name' => 'Waiting Guest',
|
||||||
|
])
|
||||||
|
->assertRedirect(route('meet.join.waiting', $room));
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('meet_participants', [
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'display_name' => 'Waiting Guest',
|
||||||
|
'status' => 'waiting',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_leave_redirects_to_feedback_page(): void
|
||||||
|
{
|
||||||
|
$room = $this->createRoom(['settings' => array_merge(config('meet.default_settings'), ['waiting_room' => false])]);
|
||||||
|
$session = Session::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'room_id' => $room->id,
|
||||||
|
'media_room_name' => 'room-'.$room->uuid,
|
||||||
|
'status' => 'live',
|
||||||
|
'started_at' => now(),
|
||||||
|
]);
|
||||||
|
$participant = \App\Models\Participant::create([
|
||||||
|
'owner_ref' => $this->user->public_id,
|
||||||
|
'session_id' => $session->id,
|
||||||
|
'display_name' => 'Guest',
|
||||||
|
'role' => 'guest',
|
||||||
|
'status' => 'joined',
|
||||||
|
'joined_at' => now(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->withSession(["meet.participant.{$session->uuid}" => $participant->uuid])
|
||||||
|
->post('/room/'.$session->uuid.'/leave')
|
||||||
|
->assertRedirect(route('meet.left', $session));
|
||||||
|
}
|
||||||
|
|
||||||
public function test_authenticated_user_can_join_live_meeting_without_display_name(): void
|
public function test_authenticated_user_can_join_live_meeting_without_display_name(): void
|
||||||
{
|
{
|
||||||
$room = $this->createRoom();
|
$room = $this->createRoom();
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ import tailwindcss from '@tailwindcss/vite';
|
|||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
laravel({
|
laravel({
|
||||||
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/meet-room.js'],
|
input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/meet-room.js', 'resources/js/meet-public.js'],
|
||||||
refresh: true,
|
refresh: true,
|
||||||
}),
|
}),
|
||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
|
|||||||
Reference in New Issue
Block a user