Route webinar and conference registration through Ladill Events.
Deploy Ladill Meet / deploy (push) Successful in 1m26s
Deploy Ladill Meet / deploy (push) Successful in 1m26s
Add Events linking UI and service client, remove Meet-native registration and invitation flows for these room types, and redirect attendees to Events registration when joining. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,6 +62,10 @@ MEET_API_KEY_HR=
|
||||
MEET_API_KEY_SUPPORT=
|
||||
MEET_API_KEY_INVOICE=
|
||||
|
||||
# --- Ladill Events (registration for webinars & conferences) ---
|
||||
LADILL_EVENTS_APP_URL=https://events.ladill.com
|
||||
LADILL_EVENTS_API_URL=https://events.ladill.com/api/service/v1
|
||||
|
||||
# --- Ladill Mail calendar (auto-sync scheduled meetings) ---
|
||||
LADILL_MAIL_API_URL=https://mail.ladill.com/api/service/v1
|
||||
MAIL_API_KEY_MEET=
|
||||
|
||||
@@ -52,15 +52,8 @@ class ConferenceController extends Controller
|
||||
$this->authorizeAbility($request, 'meetings.create');
|
||||
$organization = $this->organization($request);
|
||||
|
||||
$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,
|
||||
'members' => $members,
|
||||
'timezones' => timezone_identifiers_list(),
|
||||
'defaultSettings' => config('meet.default_settings'),
|
||||
]);
|
||||
@@ -78,9 +71,6 @@ class ConferenceController extends Controller
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
|
||||
'timezone' => ['nullable', 'timezone'],
|
||||
'passcode' => ['nullable', 'string', 'max:20'],
|
||||
'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'],
|
||||
@@ -91,12 +81,6 @@ class ConferenceController extends Controller
|
||||
'panel_discussions' => ['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'),
|
||||
@@ -109,7 +93,7 @@ class ConferenceController extends Controller
|
||||
'stage_layout' => 'plenary',
|
||||
'panels' => [],
|
||||
'stage_mode' => true,
|
||||
'presenter_refs' => $presenterRefs,
|
||||
'presenter_refs' => [],
|
||||
];
|
||||
|
||||
$data = array_merge($validated, [
|
||||
@@ -121,14 +105,6 @@ class ConferenceController extends Controller
|
||||
|
||||
$room = $this->rooms->create($request->user(), $organization, $data);
|
||||
|
||||
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
|
||||
$invites = collect(preg_split('/[\s,;]+/', $emails))
|
||||
->filter()
|
||||
->map(fn ($email) => ['email' => $email, 'role' => 'panelist'])
|
||||
->all();
|
||||
$this->invitations->inviteToRoom($room, $invites, $request->user());
|
||||
}
|
||||
|
||||
if ($room->scheduled_at) {
|
||||
$this->calendar->syncRoomCreated($room, $request->user());
|
||||
}
|
||||
@@ -144,43 +120,7 @@ class ConferenceController extends Controller
|
||||
|
||||
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
|
||||
|
||||
$presenterRefs = $this->conferences->presenterRefs($room);
|
||||
$speakers = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $room->organization_id)
|
||||
->whereIn('user_ref', $presenterRefs)
|
||||
->get();
|
||||
|
||||
$speakerUsers = User::query()
|
||||
->whereIn('public_id', $presenterRefs)
|
||||
->get()
|
||||
->keyBy('public_id');
|
||||
|
||||
$members = Member::owned($this->ownerRef($request))
|
||||
->where('organization_id', $room->organization_id)
|
||||
->where('user_ref', '!=', $this->ownerRef($request))
|
||||
->orderBy('user_ref')
|
||||
->get();
|
||||
|
||||
$memberUsers = User::query()
|
||||
->whereIn('public_id', $members->pluck('user_ref'))
|
||||
->get()
|
||||
->keyBy('public_id');
|
||||
|
||||
$canManageSpeakers = app(\App\Services\Meet\MeetPermissions::class)
|
||||
->can($this->member($request), 'meetings.manage');
|
||||
|
||||
$eventsEventUrl = \App\Support\EventsSourceLink::eventAdminUrl($room);
|
||||
|
||||
return view('meet.conferences.show', compact(
|
||||
'room',
|
||||
'speakers',
|
||||
'speakerUsers',
|
||||
'members',
|
||||
'memberUsers',
|
||||
'presenterRefs',
|
||||
'canManageSpeakers',
|
||||
'eventsEventUrl',
|
||||
));
|
||||
return view('meet.conferences.show', compact('room'));
|
||||
}
|
||||
|
||||
public function updateSpeakers(Request $request, Room $room): RedirectResponse
|
||||
@@ -189,6 +129,17 @@ class ConferenceController extends Controller
|
||||
$this->authorizeOwner($request, $room);
|
||||
abort_unless($room->isConference(), 404);
|
||||
|
||||
if ($room->isEventsLinked()) {
|
||||
if ($request->has('panel_discussions')) {
|
||||
$settings = array_merge($room->settings ?? [], [
|
||||
'panel_discussions' => $request->boolean('panel_discussions'),
|
||||
]);
|
||||
$room->update(['settings' => $settings]);
|
||||
}
|
||||
|
||||
return redirect()->route('meet.conferences.show', $room)->with('success', 'Conference settings updated.');
|
||||
}
|
||||
|
||||
$validated = $request->validate([
|
||||
'presenter_refs' => ['nullable', 'array'],
|
||||
'presenter_refs.*' => ['string', 'max:64'],
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Meet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\Meet\Concerns\ScopesToAccount;
|
||||
use App\Models\Room;
|
||||
use App\Services\Integrations\EventsClient;
|
||||
use App\Services\Integrations\EventsLinkService;
|
||||
use App\Support\EventsSourceLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class EventsLinkController extends Controller
|
||||
{
|
||||
use ScopesToAccount;
|
||||
|
||||
public function __construct(
|
||||
protected EventsLinkService $links,
|
||||
protected EventsClient $eventsClient,
|
||||
) {}
|
||||
|
||||
public function show(Request $request, Room $room): View|RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.manage');
|
||||
$this->authorizeOwner($request, $room);
|
||||
abort_unless($room->isWebinar() || $room->isConference(), 404);
|
||||
|
||||
if (! $this->eventsClient->isConfigured()) {
|
||||
return redirect()
|
||||
->route($room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show', $room)
|
||||
->withErrors(['events' => 'Ladill Events integration is not configured on this Meet instance.']);
|
||||
}
|
||||
|
||||
$events = $this->links->linkableEvents($request->user());
|
||||
$expectedType = $room->isConference() ? 'town_hall' : 'webinar';
|
||||
|
||||
return view('meet.events.link', [
|
||||
'room' => $room,
|
||||
'events' => $events,
|
||||
'expectedType' => $expectedType,
|
||||
'eventsAppUrl' => EventsSourceLink::baseUrl(),
|
||||
'isLinked' => EventsSourceLink::isLinked($room),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request, Room $room): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.manage');
|
||||
$this->authorizeOwner($request, $room);
|
||||
abort_unless($room->isWebinar() || $room->isConference(), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'event_id' => ['required', 'integer', 'min:1'],
|
||||
'virtual_session_id' => ['nullable', 'string', 'max:64'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$this->links->link(
|
||||
$room,
|
||||
$request->user(),
|
||||
(int) $validated['event_id'],
|
||||
$validated['virtual_session_id'] ?? null,
|
||||
);
|
||||
} catch (\RuntimeException $e) {
|
||||
return back()->withErrors(['events' => $e->getMessage()])->withInput();
|
||||
}
|
||||
|
||||
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
|
||||
|
||||
return redirect()->route($route, $room)->with('success', 'Linked to Ladill Events. Registration and invitations are now managed in Events.');
|
||||
}
|
||||
|
||||
public function destroy(Request $request, Room $room): RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.manage');
|
||||
$this->authorizeOwner($request, $room);
|
||||
|
||||
$this->links->unlink($room);
|
||||
|
||||
$route = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
|
||||
|
||||
return redirect()->route($route, $room)->with('success', 'Events link removed.');
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use App\Models\Invitation;
|
||||
use App\Models\Room;
|
||||
use App\Services\Meet\ContactService;
|
||||
use App\Services\Meet\InvitationService;
|
||||
use App\Support\EventsSourceLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -24,11 +25,25 @@ class InvitationController extends Controller
|
||||
protected ContactService $contacts,
|
||||
) {}
|
||||
|
||||
public function index(Request $request, Room $room): View
|
||||
public function index(Request $request, Room $room): View|RedirectResponse
|
||||
{
|
||||
$this->authorizeAbility($request, 'meetings.manage');
|
||||
$this->authorizeOwner($request, $room);
|
||||
|
||||
if ($room->isWebinar() || $room->isConference()) {
|
||||
$adminUrl = EventsSourceLink::eventCommsUrl($room) ?? EventsSourceLink::eventAdminUrl($room);
|
||||
|
||||
if ($adminUrl) {
|
||||
return redirect()->away($adminUrl);
|
||||
}
|
||||
|
||||
$showRoute = $room->isWebinar() ? 'meet.webinars.show' : 'meet.conferences.show';
|
||||
|
||||
return redirect()
|
||||
->route($showRoute, $room)
|
||||
->withErrors(['invitations' => 'Link this session to Ladill Events to send bulk invitations.']);
|
||||
}
|
||||
|
||||
$room->load(['invitations' => fn ($q) => $q->orderByDesc('created_at')]);
|
||||
$groups = ContactGroup::owned($this->ownerRef($request))
|
||||
->where('organization_id', $room->organization_id)
|
||||
|
||||
@@ -11,7 +11,7 @@ use App\Services\Meet\InvitationService;
|
||||
use App\Services\Meet\SecurityPolicyService;
|
||||
use App\Services\Meet\SessionService;
|
||||
use App\Services\Meet\SpaceService;
|
||||
use App\Services\Meet\WebinarService;
|
||||
use App\Support\EventsSourceLink;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -23,7 +23,6 @@ class JoinController extends Controller
|
||||
public function __construct(
|
||||
protected SessionService $sessions,
|
||||
protected SecurityPolicyService $security,
|
||||
protected WebinarService $webinars,
|
||||
protected SpaceService $spaces,
|
||||
protected ConferenceService $conferences,
|
||||
) {}
|
||||
@@ -165,19 +164,35 @@ class JoinController extends Controller
|
||||
$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
|
||||
? $this->webinars->findByAccessToken($token)
|
||||
: ($email ? $this->webinars->findApprovedRegistration($room, $email) : null);
|
||||
|
||||
if (! $reg) {
|
||||
return redirect()->route('meet.webinar.register', $room)
|
||||
->withErrors(['register' => 'Approved registration is required for this webinar.']);
|
||||
if ($role === 'attendee' && $room->isEventsLinked()) {
|
||||
$joinEmail = $email ?? $user?->email;
|
||||
if (! $room->hasEventsRegistrationInvite($joinEmail)) {
|
||||
$registrationUrl = EventsSourceLink::eventRegistrationUrl($room);
|
||||
|
||||
return $registrationUrl
|
||||
? redirect()->away($registrationUrl)
|
||||
: back()->withErrors(['join' => 'Register for this conference in Ladill Events before joining.']);
|
||||
}
|
||||
}
|
||||
} elseif ($room->isWebinar() && $role !== 'host') {
|
||||
if ($room->isEventsLinked()) {
|
||||
$joinEmail = $email ?? $user?->email;
|
||||
if (! $room->hasEventsRegistrationInvite($joinEmail)) {
|
||||
$registrationUrl = EventsSourceLink::eventRegistrationUrl($room);
|
||||
|
||||
$role = 'attendee';
|
||||
$displayName = $reg->display_name;
|
||||
return $registrationUrl
|
||||
? redirect()->away($registrationUrl)
|
||||
: back()->withErrors(['join' => 'Register for this webinar in Ladill Events before joining.']);
|
||||
}
|
||||
|
||||
$role = 'attendee';
|
||||
$displayName = $displayName ?? $user?->name;
|
||||
} else {
|
||||
return back()->withErrors([
|
||||
'join' => 'This webinar must be linked to a Ladill Events event before attendees can register and join.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if ($room->isConference() && ! $this->conferences->canJoinSession($room, $session, $role)) {
|
||||
|
||||
@@ -65,13 +65,11 @@ class WebinarController extends Controller
|
||||
'duration_minutes' => ['nullable', 'integer', 'min:15', 'max:480'],
|
||||
'timezone' => ['nullable', 'timezone'],
|
||||
'passcode' => ['nullable', 'string', 'max:20'],
|
||||
'invite_emails' => ['nullable', 'string'],
|
||||
'waiting_room' => ['sometimes', 'boolean'],
|
||||
'join_before_host' => ['sometimes', 'boolean'],
|
||||
'mute_on_join' => ['sometimes', 'boolean'],
|
||||
'auto_record' => ['sometimes', 'boolean'],
|
||||
'live_captions' => ['sometimes', 'boolean'],
|
||||
'webinar_auto_approve' => ['sometimes', 'boolean'],
|
||||
'panel_discussions' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
|
||||
@@ -81,7 +79,6 @@ class WebinarController extends Controller
|
||||
'mute_on_join' => $request->boolean('mute_on_join', true),
|
||||
'auto_record' => $request->boolean('auto_record'),
|
||||
'live_captions' => $request->boolean('live_captions'),
|
||||
'webinar_auto_approve' => $request->boolean('webinar_auto_approve'),
|
||||
'panel_discussions' => $request->boolean('panel_discussions'),
|
||||
'stage_layout' => 'plenary',
|
||||
'panels' => [],
|
||||
@@ -97,14 +94,6 @@ class WebinarController extends Controller
|
||||
|
||||
$room = $this->rooms->create($request->user(), $organization, $data);
|
||||
|
||||
if ($emails = trim((string) ($validated['invite_emails'] ?? ''))) {
|
||||
$invites = collect(preg_split('/[\s,;]+/', $emails))
|
||||
->filter()
|
||||
->map(fn ($email) => ['email' => $email])
|
||||
->all();
|
||||
$this->invitations->inviteToRoom($room, $invites, $request->user());
|
||||
}
|
||||
|
||||
if ($room->scheduled_at) {
|
||||
$this->calendar->syncRoomCreated($room, $request->user());
|
||||
}
|
||||
@@ -118,11 +107,9 @@ class WebinarController extends Controller
|
||||
$this->authorizeOwner($request, $room);
|
||||
abort_unless($room->isWebinar(), 404);
|
||||
|
||||
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles', 'webinarRegistrations']);
|
||||
$room->load(['sessions.participants', 'invitations', 'sessions.recordings', 'sessions.aiSummaries', 'sessionFiles']);
|
||||
|
||||
$eventsEventUrl = \App\Support\EventsSourceLink::eventAdminUrl($room);
|
||||
|
||||
return view('meet.webinars.show', compact('room', 'eventsEventUrl'));
|
||||
return view('meet.webinars.show', compact('room'));
|
||||
}
|
||||
|
||||
public function start(Request $request, Room $room): RedirectResponse
|
||||
|
||||
@@ -3,23 +3,12 @@
|
||||
namespace App\Http\Controllers\Meet;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\BreakoutRoom;
|
||||
use App\Models\MeetPoll;
|
||||
use App\Models\QaQuestion;
|
||||
use App\Models\Room;
|
||||
use App\Models\Session;
|
||||
use App\Models\SessionFile;
|
||||
use App\Models\Whiteboard;
|
||||
use App\Services\Meet\BreakoutService;
|
||||
use App\Services\Meet\FileSharingService;
|
||||
use App\Services\Meet\PollService;
|
||||
use App\Services\Meet\QaService;
|
||||
use App\Services\Meet\WebinarService;
|
||||
use App\Services\Meet\WhiteboardService;
|
||||
use App\Support\EventsSourceLink;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class WebinarRegistrationController extends Controller
|
||||
{
|
||||
@@ -27,62 +16,69 @@ class WebinarRegistrationController extends Controller
|
||||
protected WebinarService $webinars,
|
||||
) {}
|
||||
|
||||
public function show(Room $room): View
|
||||
public function show(Room $room): View|RedirectResponse
|
||||
{
|
||||
abort_unless($room->isWebinar(), 404);
|
||||
|
||||
return view('meet.webinar.register', compact('room'));
|
||||
return $this->redirectToEvents($room);
|
||||
}
|
||||
|
||||
public function store(Request $request, Room $room): View
|
||||
public function store(Request $request, Room $room): View|RedirectResponse
|
||||
{
|
||||
abort_unless($room->isWebinar(), 404);
|
||||
|
||||
$validated = $request->validate([
|
||||
'email' => ['required', 'email'],
|
||||
'display_name' => ['required', 'string', 'max:100'],
|
||||
]);
|
||||
|
||||
$registration = $this->webinars->register(
|
||||
$room,
|
||||
$validated['email'],
|
||||
$validated['display_name'],
|
||||
);
|
||||
|
||||
return view('meet.webinar.register-done', compact('room', 'registration'));
|
||||
return $this->redirectToEvents($room);
|
||||
}
|
||||
|
||||
public function manage(Request $request, Room $room): View
|
||||
public function manage(Request $request, Room $room): View|RedirectResponse
|
||||
{
|
||||
$this->authorizeWebinarHost($request, $room);
|
||||
|
||||
$registrations = $room->webinarRegistrations()->orderByDesc('created_at')->get();
|
||||
|
||||
return view('meet.webinar.registrations', compact('room', 'registrations'));
|
||||
return $this->redirectToEventsAdmin($room);
|
||||
}
|
||||
|
||||
public function approve(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
|
||||
{
|
||||
$this->authorizeWebinarHost($request, $room);
|
||||
abort_unless($registration->room_id === $room->id, 404);
|
||||
|
||||
$this->webinars->approve($registration, $request->user());
|
||||
|
||||
return back()->with('success', 'Registration approved.');
|
||||
return $this->redirectToEventsAdmin($room);
|
||||
}
|
||||
|
||||
public function reject(Request $request, Room $room, \App\Models\WebinarRegistration $registration): RedirectResponse
|
||||
{
|
||||
$this->authorizeWebinarHost($request, $room);
|
||||
abort_unless($registration->room_id === $room->id, 404);
|
||||
|
||||
$this->webinars->reject($registration);
|
||||
|
||||
return back()->with('success', 'Registration rejected.');
|
||||
return $this->redirectToEventsAdmin($room);
|
||||
}
|
||||
|
||||
protected function authorizeWebinarHost(Request $request, Room $room): void
|
||||
{
|
||||
abort_unless($request->user()?->ownerRef() === $room->host_user_ref, 403);
|
||||
}
|
||||
|
||||
protected function redirectToEvents(Room $room): RedirectResponse
|
||||
{
|
||||
$url = EventsSourceLink::eventRegistrationUrl($room);
|
||||
|
||||
if ($url) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('meet.webinars.show', $room)
|
||||
->withErrors(['register' => 'Link this webinar to Ladill Events to enable registration.']);
|
||||
}
|
||||
|
||||
protected function redirectToEventsAdmin(Room $room): RedirectResponse
|
||||
{
|
||||
$url = EventsSourceLink::eventAdminUrl($room);
|
||||
|
||||
if ($url) {
|
||||
return redirect()->away($url);
|
||||
}
|
||||
|
||||
return redirect()
|
||||
->route('meet.webinars.show', $room)
|
||||
->withErrors(['register' => 'Link this webinar to Ladill Events to manage registrations.']);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,4 +194,21 @@ class Room extends Model
|
||||
{
|
||||
return rtrim((string) config('app.meet_url', 'https://meet.ladill.com'), '/').'/r/'.$this->uuid;
|
||||
}
|
||||
|
||||
public function isEventsLinked(): bool
|
||||
{
|
||||
return \App\Support\EventsSourceLink::isLinked($this);
|
||||
}
|
||||
|
||||
public function hasEventsRegistrationInvite(?string $email): bool
|
||||
{
|
||||
if ($email === null || trim($email) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->invitations()
|
||||
->where('email', $email)
|
||||
->whereIn('status', ['pending', 'accepted'])
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
class EventsClient
|
||||
{
|
||||
private function client()
|
||||
{
|
||||
return Http::baseUrl(rtrim((string) config('meet.events_api_url'), '/'))
|
||||
->withToken((string) config('meet.events_api_key'))
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->connectTimeout(10)
|
||||
->timeout(20);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return filled(config('meet.events_api_url')) && filled(config('meet.events_api_key'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function linkableEvents(string $ownerRef): array
|
||||
{
|
||||
$response = $this->get('events', ['owner_ref' => $ownerRef]);
|
||||
|
||||
return (array) ($response['events'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function linkRoom(int $eventId, array $payload): array
|
||||
{
|
||||
$response = $this->post("events/{$eventId}/link-room", $payload);
|
||||
|
||||
return (array) ($response['event'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function get(string $path, array $query = []): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client()->get($path, $query);
|
||||
} catch (ConnectionException) {
|
||||
throw new \RuntimeException('Could not reach Ladill Events.');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('Events service error: '.($response->json('message') ?? $response->body()));
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function post(string $path, array $data): array
|
||||
{
|
||||
try {
|
||||
$response = $this->client()->post($path, $data);
|
||||
} catch (ConnectionException) {
|
||||
throw new \RuntimeException('Could not reach Ladill Events.');
|
||||
}
|
||||
|
||||
if ($response->failed()) {
|
||||
throw new \RuntimeException('Events service error: '.($response->json('message') ?? $response->body()));
|
||||
}
|
||||
|
||||
return (array) $response->json();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Integrations;
|
||||
|
||||
use App\Models\Room;
|
||||
use App\Models\User;
|
||||
use App\Support\EventsSourceLink;
|
||||
|
||||
class EventsLinkService
|
||||
{
|
||||
public function __construct(private readonly EventsClient $events) {}
|
||||
|
||||
/**
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
public function linkableEvents(User $user): array
|
||||
{
|
||||
if (! $this->events->isConfigured()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->events->linkableEvents($user->ownerRef());
|
||||
}
|
||||
|
||||
public function link(Room $room, User $user, int $eventId, ?string $virtualSessionId = null): Room
|
||||
{
|
||||
abort_unless($room->isWebinar() || $room->isConference(), 422);
|
||||
|
||||
$result = $this->events->linkRoom($eventId, [
|
||||
'owner_ref' => $user->ownerRef(),
|
||||
'meet_room_uuid' => $room->uuid,
|
||||
'meet_room_type' => $room->isConference() ? 'town_hall' : 'webinar',
|
||||
'join_url' => $room->joinUrl(),
|
||||
'title' => $room->title,
|
||||
'scheduled_at' => $room->scheduled_at?->toIso8601String(),
|
||||
'duration_minutes' => $room->duration_minutes ?? 60,
|
||||
'virtual_session_id' => $virtualSessionId,
|
||||
]);
|
||||
|
||||
$room->update([
|
||||
'source' => [
|
||||
'app' => 'events',
|
||||
'entity_type' => 'virtual_session',
|
||||
'entity_id' => (string) ($result['entity_id'] ?? ''),
|
||||
'event_id' => (string) $eventId,
|
||||
'event_title' => (string) ($result['title'] ?? ''),
|
||||
'registration_url' => (string) ($result['registration_url'] ?? ''),
|
||||
'short_code' => (string) ($result['short_code'] ?? ''),
|
||||
],
|
||||
]);
|
||||
|
||||
return $room->fresh();
|
||||
}
|
||||
|
||||
public function unlink(Room $room): Room
|
||||
{
|
||||
abort_unless(EventsSourceLink::isLinked($room), 422);
|
||||
|
||||
$room->update(['source' => null]);
|
||||
|
||||
return $room->fresh();
|
||||
}
|
||||
}
|
||||
@@ -6,21 +6,97 @@ use App\Models\Room;
|
||||
|
||||
class EventsSourceLink
|
||||
{
|
||||
public static function eventAdminUrl(Room $room): ?string
|
||||
public static function isLinked(Room $room): bool
|
||||
{
|
||||
$source = (array) ($room->source ?? []);
|
||||
|
||||
if (($source['app'] ?? '') !== 'events') {
|
||||
return ($source['app'] ?? '') === 'events' && (int) ($source['event_id'] ?? 0) > 0;
|
||||
}
|
||||
|
||||
public static function eventId(Room $room): ?int
|
||||
{
|
||||
if (! self::isLinked($room)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$eventId = (int) ($source['event_id'] ?? 0);
|
||||
if ($eventId <= 0) {
|
||||
return (int) ((array) ($room->source ?? []))['event_id'];
|
||||
}
|
||||
|
||||
public static function baseUrl(): string
|
||||
{
|
||||
return rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
|
||||
}
|
||||
|
||||
public static function eventAdminUrl(Room $room): ?string
|
||||
{
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees' : null;
|
||||
}
|
||||
|
||||
public static function eventEditUrl(Room $room): ?string
|
||||
{
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/events/'.$eventId : null;
|
||||
}
|
||||
|
||||
public static function eventRegistrationUrl(Room $room): ?string
|
||||
{
|
||||
$source = (array) ($room->source ?? []);
|
||||
$stored = trim((string) ($source['registration_url'] ?? ''));
|
||||
|
||||
if ($stored !== '') {
|
||||
return $stored;
|
||||
}
|
||||
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/q/'.($source['short_code'] ?? '') : null;
|
||||
}
|
||||
|
||||
public static function eventQrUrl(Room $room): ?string
|
||||
{
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/events/'.$eventId.'/download/png' : null;
|
||||
}
|
||||
|
||||
public static function eventCommsUrl(Room $room): ?string
|
||||
{
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/events/'.$eventId.'/attendees/comms-preview' : null;
|
||||
}
|
||||
|
||||
public static function eventBadgesUrl(Room $room): ?string
|
||||
{
|
||||
$eventId = self::eventId($room);
|
||||
|
||||
return $eventId ? self::baseUrl().'/events/'.$eventId.'/badges' : null;
|
||||
}
|
||||
|
||||
/** @return array<string, string|null> */
|
||||
public static function urls(Room $room): array
|
||||
{
|
||||
return [
|
||||
'admin' => self::eventAdminUrl($room),
|
||||
'edit' => self::eventEditUrl($room),
|
||||
'registration' => self::eventRegistrationUrl($room),
|
||||
'qr' => self::eventQrUrl($room),
|
||||
'comms' => self::eventCommsUrl($room),
|
||||
'badges' => self::eventBadgesUrl($room),
|
||||
];
|
||||
}
|
||||
|
||||
public static function eventTitle(Room $room): ?string
|
||||
{
|
||||
if (! self::isLinked($room)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$base = rtrim((string) config('meet.events_app_url', 'https://events.ladill.com'), '/');
|
||||
$title = trim((string) (((array) ($room->source ?? []))['event_title'] ?? ''));
|
||||
|
||||
return $base.'/events/'.$eventId.'/attendees';
|
||||
return $title !== '' ? $title : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,8 @@ return [
|
||||
],
|
||||
|
||||
'events_app_url' => env('LADILL_EVENTS_APP_URL', 'https://events.ladill.com'),
|
||||
'events_api_url' => env('LADILL_EVENTS_API_URL', 'https://events.ladill.com/api/service/v1'),
|
||||
'events_api_key' => env('MEET_API_KEY_EVENTS'),
|
||||
|
||||
'calendar' => [
|
||||
'google' => [
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<x-app-layout title="Schedule conference">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Schedule a conference</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Large-format events with assigned hosts and speakers. Optional green room for presenters before going live.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">After creating the conference, link it to a virtual or hybrid event in Ladill Events for registration, speakers, invitations, and QR codes. Optional green room for presenters before going live.</p>
|
||||
|
||||
<form method="POST" action="{{ route('meet.conferences.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@@ -36,26 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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">
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-violet-600">Conference</p>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
|
||||
@if(! empty($eventsEventUrl))
|
||||
<a href="{{ $eventsEventUrl }}" target="_blank" rel="noopener" class="mt-2 inline-flex text-sm font-medium text-violet-600 hover:text-violet-800">
|
||||
View in Ladill Events →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($errors->any())
|
||||
@@ -17,6 +12,14 @@
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (session('success'))
|
||||
<div class="mt-4 rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{{ session('success') }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@include('meet.partials.events-integration', ['room' => $room])
|
||||
|
||||
<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">
|
||||
@@ -39,11 +42,9 @@
|
||||
</form>
|
||||
@endif
|
||||
<a href="{{ route('meet.rooms.ical', $room) }}" class="btn-secondary btn-secondary-sm">Download iCal</a>
|
||||
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
|
||||
@if ($room->sessions->isNotEmpty())
|
||||
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
|
||||
@endif
|
||||
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
|
||||
</div>
|
||||
|
||||
@if ($room->passcode)
|
||||
@@ -59,78 +60,18 @@
|
||||
@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
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-medium text-slate-700">Speakers</h2>
|
||||
<p class="mt-1 text-xs text-slate-500">Team members on stage and external speakers invited by email. You are always the host.</p>
|
||||
|
||||
@if ($speakers->isNotEmpty() || $room->invitations->where('role', 'panelist')->isNotEmpty())
|
||||
<div class="mt-4 space-y-3">
|
||||
@if ($speakers->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500">Assigned speakers</h3>
|
||||
<ul class="mt-2 space-y-1 text-sm text-slate-600">
|
||||
@foreach ($speakers as $speaker)
|
||||
@php $user = $speakerUsers->get($speaker->user_ref); @endphp
|
||||
<li>{{ $user?->name ?? $speaker->user_ref }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($room->invitations->where('role', 'panelist')->isNotEmpty())
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-slate-500">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->display_name ?? $invite->email }}
|
||||
<span class="text-slate-400">· {{ ucfirst($invite->status) }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@else
|
||||
<p class="mt-3 text-sm text-slate-500">No speakers assigned yet.</p>
|
||||
@endif
|
||||
|
||||
@if ($canManageSpeakers)
|
||||
<form method="POST" action="{{ route('meet.conferences.speakers.update', $room) }}" class="mt-5 space-y-4 border-t border-slate-100 pt-5">
|
||||
@if ($room->status === 'scheduled')
|
||||
<form method="POST" action="{{ route('meet.conferences.speakers.update', $room) }}" class="mt-6 border-t border-slate-100 pt-6">
|
||||
@csrf
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Invite speakers by email</label>
|
||||
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="speaker@example.com, another@example.com"
|
||||
class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
<p class="mt-1 text-xs text-slate-500">Comma-separated. Invitations are sent as speaker (panelist) roles.</p>
|
||||
</div>
|
||||
|
||||
@if ($members->isNotEmpty())
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Assigned speakers</label>
|
||||
<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)
|
||||
@php $user = $memberUsers->get($member->user_ref); @endphp
|
||||
<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', $presenterRefs))) class="rounded border-slate-300">
|
||||
<span>{{ $user?->name ?? $member->user_ref }}</span>
|
||||
</label>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-700">
|
||||
<h2 class="text-sm font-medium text-slate-700">Stage layout</h2>
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-700">
|
||||
<input type="checkbox" name="panel_discussions" value="1"
|
||||
@checked(old('panel_discussions', $room->setting('panel_discussions'))) class="rounded border-slate-300">
|
||||
Enable panel discussions (multi-speaker stage layout)
|
||||
</label>
|
||||
|
||||
<button type="submit" class="btn-primary btn-primary-sm">Save speakers</button>
|
||||
<input type="hidden" name="presenter_refs" value="">
|
||||
<button type="submit" class="btn-primary btn-primary-sm mt-3">Save settings</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
<x-app-layout :title="'Link to Ladill Events · '.$room->title">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<a href="{{ $room->isWebinar() ? route('meet.webinars.show', $room) : route('meet.conferences.show', $room) }}" class="text-sm font-medium text-indigo-600 hover:text-indigo-800">← Back</a>
|
||||
<h1 class="mt-4 text-xl font-semibold text-slate-900">Link to Ladill Events</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">
|
||||
Choose a virtual or hybrid event. Registration, bulk invitations, speakers, and QR codes will be managed in Events.
|
||||
</p>
|
||||
|
||||
@if ($errors->any())
|
||||
<div class="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ $errors->first() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($events === [])
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6 text-sm text-slate-600">
|
||||
<p>No virtual or hybrid events found in your Ladill Events account.</p>
|
||||
<a href="{{ $eventsAppUrl }}/qr-codes/create" target="_blank" rel="noopener" class="mt-4 inline-flex btn-primary btn-primary-sm">Create event in Events</a>
|
||||
</div>
|
||||
@else
|
||||
<form method="POST" action="{{ route('meet.events.link.store', $room) }}" class="mt-6 space-y-5">
|
||||
@csrf
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<label class="block text-sm font-medium text-slate-700">Event</label>
|
||||
<select name="event_id" required class="mt-2 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Select an event…</option>
|
||||
@foreach ($events as $event)
|
||||
<option value="{{ $event['id'] }}" @selected(old('event_id') == $event['id'])>
|
||||
{{ $event['title'] }} ({{ ucfirst($event['format']) }})
|
||||
</option>
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<label class="block text-sm font-medium text-slate-700">Virtual session slot (optional)</label>
|
||||
<p class="mt-1 text-xs text-slate-500">Pick an empty session matching this {{ $room->isConference() ? 'conference' : 'webinar' }}, or leave blank to attach to the first available {{ $expectedType === 'town_hall' ? 'conference' : 'webinar' }} slot.</p>
|
||||
<select name="virtual_session_id" class="mt-2 w-full rounded-lg border-slate-300 text-sm">
|
||||
<option value="">Auto-assign slot</option>
|
||||
@foreach ($events as $event)
|
||||
@foreach ($event['virtual_sessions'] ?? [] as $session)
|
||||
@php
|
||||
$typeMatch = ($session['meet_room_type'] ?? '') === $expectedType;
|
||||
$available = empty($session['meet_room_uuid']);
|
||||
@endphp
|
||||
@if ($typeMatch)
|
||||
<option value="{{ $session['id'] }}" data-event="{{ $event['id'] }}" @selected(old('virtual_session_id') === $session['id'])>
|
||||
[{{ $event['title'] }}] {{ $session['title'] ?: 'Untitled session' }}{{ $available ? '' : ' (linked)' }}
|
||||
</option>
|
||||
@endif
|
||||
@endforeach
|
||||
@endforeach
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary btn-primary-lg w-full">Link event</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
@@ -0,0 +1,79 @@
|
||||
@php
|
||||
use App\Support\EventsSourceLink;
|
||||
|
||||
$eventsUrls = EventsSourceLink::urls($room);
|
||||
$isLinked = EventsSourceLink::isLinked($room);
|
||||
$eventTitle = EventsSourceLink::eventTitle($room);
|
||||
$eventsBase = EventsSourceLink::baseUrl();
|
||||
$kind = $room->isConference() ? 'conference' : 'webinar';
|
||||
@endphp
|
||||
|
||||
<div class="mt-6 rounded-2xl border {{ $isLinked ? 'border-indigo-200 bg-indigo-50/40' : 'border-amber-200 bg-amber-50/50' }} p-6">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-slate-900">Ladill Events</h2>
|
||||
@if ($isLinked && $eventTitle)
|
||||
<p class="mt-1 text-sm text-slate-600">Linked to <span class="font-medium">{{ $eventTitle }}</span></p>
|
||||
@elseif ($isLinked)
|
||||
<p class="mt-1 text-sm text-slate-600">Linked to event #{{ EventsSourceLink::eventId($room) }}</p>
|
||||
@else
|
||||
<p class="mt-1 text-sm text-slate-600">
|
||||
Registration, invitations, speakers, and QR codes for this {{ $kind }} are managed in Ladill Events.
|
||||
Link a virtual or hybrid event to enable attendee registration.
|
||||
</p>
|
||||
@endif
|
||||
</div>
|
||||
@if ($isLinked)
|
||||
<form method="POST" action="{{ route('meet.events.link.destroy', $room) }}" onsubmit="return confirm('Remove the Events link? Registration settings stay in Events but this room will no longer show as linked.');">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<button type="submit" class="text-xs font-medium text-slate-500 hover:text-slate-700">Unlink</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if ($isLinked)
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
@if ($eventsUrls['registration'])
|
||||
<div class="rounded-lg border border-white/80 bg-white p-3">
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-slate-500">Registration page</p>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input type="text" readonly value="{{ $eventsUrls['registration'] }}" class="min-w-0 flex-1 rounded border-slate-200 bg-slate-50 text-xs font-mono">
|
||||
<x-copy-button :text="$eventsUrls['registration']" class="btn-secondary btn-secondary-sm shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
@if ($eventsUrls['admin'])
|
||||
<a href="{{ $eventsUrls['admin'] }}" target="_blank" rel="noopener" class="rounded-lg border border-white/80 bg-white p-3 text-sm font-medium text-indigo-700 hover:bg-indigo-50">
|
||||
Attendees & check-in →
|
||||
</a>
|
||||
@endif
|
||||
@if ($eventsUrls['edit'])
|
||||
<a href="{{ $eventsUrls['edit'] }}" target="_blank" rel="noopener" class="rounded-lg border border-white/80 bg-white p-3 text-sm font-medium text-indigo-700 hover:bg-indigo-50">
|
||||
Event settings (tickets, branding) →
|
||||
</a>
|
||||
@endif
|
||||
@if ($eventsUrls['comms'])
|
||||
<a href="{{ $eventsUrls['comms'] }}" target="_blank" rel="noopener" class="rounded-lg border border-white/80 bg-white p-3 text-sm font-medium text-indigo-700 hover:bg-indigo-50">
|
||||
Bulk invitations & comms →
|
||||
</a>
|
||||
@endif
|
||||
@if ($eventsUrls['qr'])
|
||||
<a href="{{ $eventsUrls['qr'] }}" target="_blank" rel="noopener" class="rounded-lg border border-white/80 bg-white p-3 text-sm font-medium text-indigo-700 hover:bg-indigo-50">
|
||||
Event QR code →
|
||||
</a>
|
||||
@endif
|
||||
@if ($eventsUrls['badges'])
|
||||
<a href="{{ $eventsUrls['badges'] }}" target="_blank" rel="noopener" class="rounded-lg border border-white/80 bg-white p-3 text-sm font-medium text-indigo-700 hover:bg-indigo-50">
|
||||
Badges & printing →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-slate-500">Programme speakers and panelists are configured on the event programme in Events and sync to this session when linked.</p>
|
||||
@else
|
||||
<div class="mt-4 flex flex-wrap gap-3">
|
||||
<a href="{{ route('meet.events.link', $room) }}" class="btn-primary btn-primary-sm">Link to Ladill Events</a>
|
||||
<a href="{{ $eventsBase }}/qr-codes/create" target="_blank" rel="noopener" class="btn-secondary btn-secondary-sm">Create event in Events</a>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
<x-app-layout title="Schedule webinar">
|
||||
<div class="mx-auto max-w-xl">
|
||||
<h1 class="text-xl font-semibold text-slate-900">Schedule a webinar</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">Attendees register in advance. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.</p>
|
||||
<p class="mt-1 text-sm text-slate-500">After creating the webinar, link it to a virtual or hybrid event in Ladill Events for registration, invitations, and QR codes. Billed at GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant when the session ends.</p>
|
||||
|
||||
<form method="POST" action="{{ route('meet.webinars.store') }}" class="mt-6 space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
@csrf
|
||||
@@ -36,11 +36,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Invite panelists by email (comma-separated)</label>
|
||||
<input type="text" name="invite_emails" value="{{ old('invite_emails') }}" placeholder="panelist@example.com" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-700">Passcode (optional)</label>
|
||||
<input type="text" name="passcode" value="{{ old('passcode') }}" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
|
||||
@@ -52,14 +47,13 @@
|
||||
<input type="checkbox" name="waiting_room" value="1" @checked(old('waiting_room', $defaultSettings['waiting_room'] ?? true)) class="mt-0.5 rounded border-slate-300">
|
||||
<span>
|
||||
<span class="font-medium text-slate-800">Waiting room</span>
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Hold registrants until you admit them.</span>
|
||||
<span class="mt-0.5 block text-xs text-slate-500">Hold attendees until you admit them.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="join_before_host" value="1" @checked(old('join_before_host')) class="rounded border-slate-300"> Allow join before host</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="mute_on_join" value="1" @checked(old('mute_on_join', $defaultSettings['mute_on_join'] ?? true)) class="rounded border-slate-300"> Mute attendees on join</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="auto_record" value="1" @checked(old('auto_record')) class="rounded border-slate-300"> Auto-record</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="live_captions" value="1" @checked(old('live_captions')) class="rounded border-slate-300"> Live captions</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="webinar_auto_approve" value="1" @checked(old('webinar_auto_approve')) class="rounded border-slate-300"> Auto-approve registrations</label>
|
||||
<label class="flex items-center gap-2 text-sm"><input type="checkbox" name="panel_discussions" value="1" @checked(old('panel_discussions')) class="rounded border-slate-300"> Enable panel discussions (multi-speaker stage layout)</label>
|
||||
</fieldset>
|
||||
|
||||
|
||||
@@ -5,11 +5,6 @@
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-600">Webinar</p>
|
||||
<h1 class="text-2xl font-semibold text-slate-900">{{ $room->title }}</h1>
|
||||
<p class="mt-1 text-sm text-slate-500">{{ config('meet.room_statuses')[$room->status] ?? $room->status }}</p>
|
||||
@if(! empty($eventsEventUrl))
|
||||
<a href="{{ $eventsEventUrl }}" target="_blank" rel="noopener" class="mt-2 inline-flex text-sm font-medium text-indigo-600 hover:text-indigo-800">
|
||||
View in Ladill Events →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
@if ($room->canRestart())
|
||||
<form method="POST" action="{{ route('meet.webinars.start', $room) }}">
|
||||
@@ -35,14 +30,10 @@
|
||||
Billed at <span class="font-medium">GHS {{ number_format(config('meet.billing.price_per_participant_ghs', 0.30), 2) }} per participant</span> (peak attendance when the session ends).
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-medium text-slate-700">Registration link</h2>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input type="text" readonly value="{{ route('meet.webinar.register', $room) }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
|
||||
<x-copy-button :text="route('meet.webinar.register', $room)" class="btn-secondary btn-secondary-sm shrink-0" />
|
||||
</div>
|
||||
@include('meet.partials.events-integration', ['room' => $room])
|
||||
|
||||
<h2 class="mt-6 text-sm font-medium text-slate-700">Join link (hosts & panelists)</h2>
|
||||
<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 (hosts & panelists)</h2>
|
||||
<div class="mt-2 flex gap-2">
|
||||
<input type="text" readonly value="{{ $room->joinUrl() }}" class="flex-1 rounded-lg border-slate-300 bg-slate-50 text-sm font-mono">
|
||||
<x-copy-button :text="$room->joinUrl()" class="btn-secondary btn-secondary-sm shrink-0" />
|
||||
@@ -50,19 +41,15 @@
|
||||
|
||||
<div class="btn-group mt-4">
|
||||
<a href="{{ route('meet.rooms.ical', $room) }}" class="btn-secondary btn-secondary-sm">Download iCal</a>
|
||||
<a href="{{ route('meet.rooms.qr', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">QR code</a>
|
||||
@if ($room->sessions->isNotEmpty())
|
||||
<a href="{{ route('meet.rooms.attendance', $room) }}" class="btn-secondary btn-secondary-sm">Attendance CSV</a>
|
||||
@endif
|
||||
<a href="{{ route('meet.invitations.index', $room) }}" class="btn-secondary btn-secondary-sm">Manage invitations</a>
|
||||
@if ($room->canRestart() && ($room->status === 'ended' || $room->sessions->isNotEmpty()))
|
||||
<form method="POST" action="{{ route('meet.webinars.start', $room) }}" class="inline">
|
||||
@csrf
|
||||
<button type="submit" class="btn-secondary btn-secondary-sm">{{ $room->restartLabel() }}</button>
|
||||
</form>
|
||||
@endif
|
||||
<a href="{{ route('meet.webinar.registrations', $room) }}" class="btn-secondary btn-secondary-sm">Registrations</a>
|
||||
<a href="{{ route('meet.webinar.register', $room) }}" target="_blank" class="btn-secondary btn-secondary-sm">Registration page</a>
|
||||
</div>
|
||||
|
||||
@if ($room->passcode)
|
||||
@@ -122,26 +109,5 @@
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($room->sessions->isNotEmpty())
|
||||
<div class="mt-6 rounded-2xl border border-slate-200 bg-white p-6">
|
||||
<h2 class="text-sm font-medium text-slate-700">Sessions</h2>
|
||||
<ul class="mt-3 divide-y divide-slate-100 text-sm">
|
||||
@foreach ($room->sessions as $session)
|
||||
<li class="flex items-center justify-between py-2">
|
||||
<span>{{ $session->started_at?->format('M j, Y g:i A') ?? 'Pending' }}</span>
|
||||
<span class="text-slate-500">{{ $session->peak_participants }} participants · {{ config('meet.session_statuses')[$session->status] ?? $session->status }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($room->status === 'scheduled')
|
||||
<form method="POST" action="{{ route('meet.rooms.cancel', $room) }}" class="mt-6" onsubmit="return confirm('Cancel this webinar?')">
|
||||
@csrf
|
||||
<button type="submit" class="text-sm text-red-600 hover:underline">Cancel webinar</button>
|
||||
</form>
|
||||
@endif
|
||||
</div>
|
||||
</x-app-layout>
|
||||
|
||||
@@ -143,6 +143,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::post('/conferences/{room}/speakers', [ConferenceController::class, 'updateSpeakers'])->name('meet.conferences.speakers.update');
|
||||
Route::post('/conferences/{room}/start', [ConferenceController::class, 'start'])->name('meet.conferences.start');
|
||||
|
||||
Route::get('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'show'])->name('meet.events.link');
|
||||
Route::post('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'store'])->name('meet.events.link.store');
|
||||
Route::delete('/meetings/{room}/events-link', [\App\Http\Controllers\Meet\EventsLinkController::class, 'destroy'])->name('meet.events.link.destroy');
|
||||
|
||||
Route::get('/rooms', [SpaceController::class, 'index'])->name('meet.spaces.index');
|
||||
Route::get('/rooms/create', [SpaceController::class, 'create'])->name('meet.spaces.create');
|
||||
Route::post('/rooms', [SpaceController::class, 'store'])->name('meet.spaces.store');
|
||||
|
||||
@@ -630,23 +630,14 @@ class MeetWebTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_webinar_registration_flow(): void
|
||||
public function test_webinar_registration_redirects_without_events_link(): void
|
||||
{
|
||||
$room = $this->createRoom([
|
||||
'type' => 'webinar',
|
||||
'settings' => array_merge(config('meet.default_settings'), ['webinar_auto_approve' => true]),
|
||||
]);
|
||||
$room = $this->createRoom(['type' => 'webinar']);
|
||||
|
||||
$this->post('/w/'.$room->uuid.'/register', [
|
||||
'email' => 'attendee@example.com',
|
||||
'display_name' => 'Attendee One',
|
||||
])->assertOk();
|
||||
|
||||
$this->assertDatabaseHas('meet_webinar_registrations', [
|
||||
'room_id' => $room->id,
|
||||
'email' => 'attendee@example.com',
|
||||
'status' => 'approved',
|
||||
]);
|
||||
])->assertRedirect(route('meet.webinars.show', $room));
|
||||
}
|
||||
|
||||
public function test_host_can_create_webinar_room(): void
|
||||
|
||||
Reference in New Issue
Block a user