Programme sync to Meet rooms, wallet-billed mass comms, webhook lifecycle updates, and registration access bridge with join-window gating on public pages. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -41,6 +41,7 @@ IDENTITY_API_KEY_EVENTS=
|
||||
|
||||
MEET_API_URL=https://meet.ladill.com/api/service/v1
|
||||
MEET_API_KEY_EVENTS=
|
||||
MEET_WEBHOOK_SECRET_EVENTS=
|
||||
|
||||
SMS_PLATFORM_API_URL=https://ladill.com/api
|
||||
SMS_API_KEY_EVENTS=
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\Meet\MeetLifecycleService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class MeetWebhookController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, MeetLifecycleService $lifecycle): JsonResponse
|
||||
{
|
||||
$secret = (string) config('meet.webhook_secret', '');
|
||||
if ($secret === '') {
|
||||
return response()->json(['error' => 'Webhook not configured.'], 503);
|
||||
}
|
||||
|
||||
$signature = (string) $request->header('X-Ladill-Meet-Signature', '');
|
||||
$body = $request->getContent();
|
||||
$expected = hash_hmac('sha256', $body, $secret);
|
||||
|
||||
if ($signature === '' || ! hash_equals($expected, $signature)) {
|
||||
return response()->json(['error' => 'Invalid signature.'], 401);
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $payload */
|
||||
$payload = $request->json()->all();
|
||||
$event = (string) ($payload['event'] ?? '');
|
||||
|
||||
if ($event === '') {
|
||||
return response()->json(['error' => 'Missing event.'], 422);
|
||||
}
|
||||
|
||||
$lifecycle->handle($event, $payload);
|
||||
|
||||
return response()->json(['ok' => true]);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,19 @@ namespace App\Http\Controllers\Events;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Events\EventEmailService;
|
||||
use App\Services\Events\EventCommsService;
|
||||
use App\Support\Events\EventBadgeZpl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class AttendeeController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly SmsService $sms,
|
||||
private readonly EventEmailService $email,
|
||||
private readonly EventCommsService $comms,
|
||||
) {}
|
||||
|
||||
public function hub(Request $request): View
|
||||
@@ -133,7 +133,24 @@ class AttendeeController extends Controller
|
||||
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
|
||||
];
|
||||
|
||||
$content = $event->content();
|
||||
$format = (string) ($content['format'] ?? 'in_person');
|
||||
$virtualSessions = collect((array) ($content['virtual_sessions'] ?? []))->filter(fn ($s) => is_array($s));
|
||||
|
||||
if (in_array($format, ['virtual', 'hybrid'], true) && $virtualSessions->isNotEmpty()) {
|
||||
$stats['virtual_sessions'] = $virtualSessions->count();
|
||||
$stats['virtual_live'] = $virtualSessions->where('meet_status', 'live')->count();
|
||||
$stats['virtual_ended'] = $virtualSessions->where('meet_status', 'ended')->count();
|
||||
$stats['virtual_recordings'] = $virtualSessions->filter(fn ($s) => ! empty($s['recording_url']))->count();
|
||||
}
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
$joinUrl = $this->comms->primaryJoinUrl($event);
|
||||
$commsModes = array_values(array_filter([
|
||||
$programme ? EventCommsService::MODE_PROGRAMME : null,
|
||||
$joinUrl !== '' ? EventCommsService::MODE_JOIN : null,
|
||||
($programme && $joinUrl !== '') ? EventCommsService::MODE_BOTH : null,
|
||||
]));
|
||||
|
||||
return view('events.attendees', [
|
||||
'qrCode' => $event,
|
||||
@@ -141,60 +158,64 @@ class AttendeeController extends Controller
|
||||
'registrations' => $registrations,
|
||||
'stats' => $stats,
|
||||
'programme' => $programme,
|
||||
'commsModes' => $commsModes,
|
||||
'defaultCommsMode' => $commsModes[0] ?? EventCommsService::MODE_PROGRAMME,
|
||||
]);
|
||||
}
|
||||
|
||||
public function shareProgramme(QrCode $event): RedirectResponse
|
||||
public function previewComms(QrCode $event, Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
if (! $programme) {
|
||||
return back()->with('error', 'No programme outline is attached to this event yet.');
|
||||
$mode = (string) $request->query('mode', EventCommsService::MODE_PROGRAMME);
|
||||
abort_unless(in_array($mode, [EventCommsService::MODE_PROGRAMME, EventCommsService::MODE_JOIN, EventCommsService::MODE_BOTH], true), 422);
|
||||
|
||||
return response()->json($this->comms->preview($event, $mode));
|
||||
}
|
||||
|
||||
$recipients = $event->eventRegistrations()
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->get();
|
||||
public function shareComms(QrCode $event, Request $request): RedirectResponse
|
||||
{
|
||||
$this->authorize('view', $event);
|
||||
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
|
||||
|
||||
$eventName = $event->content()['name'] ?? $event->label;
|
||||
$programmeUrl = $programme->publicUrl();
|
||||
$emailed = 0;
|
||||
$texted = 0;
|
||||
$validated = $request->validate([
|
||||
'mode' => ['required', Rule::in([
|
||||
EventCommsService::MODE_PROGRAMME,
|
||||
EventCommsService::MODE_JOIN,
|
||||
EventCommsService::MODE_BOTH,
|
||||
])],
|
||||
]);
|
||||
|
||||
foreach ($recipients as $reg) {
|
||||
if ($reg->attendee_email) {
|
||||
$this->email->sendProgrammeShare(
|
||||
(string) $event->user->public_id,
|
||||
$reg->attendee_email,
|
||||
$eventName,
|
||||
$programmeUrl,
|
||||
$reg->attendee_name,
|
||||
);
|
||||
$emailed++;
|
||||
$preview = $this->comms->preview($event, $validated['mode']);
|
||||
if (! $preview['affordable']) {
|
||||
return back()->with('error', 'Insufficient wallet balance. Add funds in Billing before sending.');
|
||||
}
|
||||
|
||||
if ($reg->attendee_phone) {
|
||||
$this->sms->send(
|
||||
(string) $event->user->public_id,
|
||||
$reg->attendee_phone,
|
||||
sprintf(
|
||||
'Hi %s, the programme for %s is here: %s',
|
||||
explode(' ', $reg->attendee_name ?? 'there')[0],
|
||||
$eventName,
|
||||
$programmeUrl
|
||||
)
|
||||
);
|
||||
$texted++;
|
||||
}
|
||||
if ($preview['recipients'] === 0) {
|
||||
return back()->with('error', 'No confirmed attendees to message yet.');
|
||||
}
|
||||
|
||||
if ($emailed === 0 && $texted === 0) {
|
||||
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
|
||||
$result = $this->comms->send($event, $validated['mode']);
|
||||
|
||||
if ($result['emailed'] === 0 && $result['texted'] === 0) {
|
||||
return back()->with('error', 'No messages were sent — check that attendees have email or phone on file.');
|
||||
}
|
||||
|
||||
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
|
||||
return back()->with('success', sprintf(
|
||||
'Sent — %d email(s), %d SMS, %d skipped.',
|
||||
$result['emailed'],
|
||||
$result['texted'],
|
||||
$result['skipped'],
|
||||
));
|
||||
}
|
||||
|
||||
/** @deprecated Use shareComms with mode=programme */
|
||||
public function shareProgramme(QrCode $event): RedirectResponse
|
||||
{
|
||||
request()->merge(['mode' => EventCommsService::MODE_PROGRAMME]);
|
||||
|
||||
return $this->shareComms($event, request());
|
||||
}
|
||||
|
||||
private function programmeFor(QrCode $event): ?QrCode
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\SmsService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventCommsService
|
||||
{
|
||||
public const MODE_PROGRAMME = 'programme';
|
||||
public const MODE_JOIN = 'join';
|
||||
public const MODE_BOTH = 'both';
|
||||
|
||||
private const SMS_SEGMENT_CHARS = 160;
|
||||
private const SMS_PRICE_GHS = 0.03;
|
||||
private const EMAIL_PRICE_GHS = 0.0135;
|
||||
|
||||
public function __construct(
|
||||
private readonly EventEmailService $email,
|
||||
private readonly SmsService $sms,
|
||||
private readonly BillingClient $billing,
|
||||
private readonly EventProgrammeService $programmes,
|
||||
) {}
|
||||
|
||||
/** @return array{recipients: int, emails: int, sms: int, estimated_ghs: float, affordable: bool} */
|
||||
public function preview(QrCode $event, string $mode): array
|
||||
{
|
||||
$regs = $this->confirmedRegistrations($event);
|
||||
$emails = $regs->filter(fn ($r) => (bool) $r->attendee_email)->count();
|
||||
$phones = $regs->filter(fn ($r) => (bool) $r->attendee_phone)->count();
|
||||
$smsSegments = $phones * $this->estimateSmsSegments($event, $mode);
|
||||
$estimated = round(($emails * self::EMAIL_PRICE_GHS) + ($smsSegments * self::SMS_PRICE_GHS), 2);
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$affordable = $estimated <= 0 || $this->billing->canAfford($ownerRef, (int) round($estimated * 100));
|
||||
|
||||
return [
|
||||
'recipients' => $regs->count(),
|
||||
'emails' => $emails,
|
||||
'sms' => $phones,
|
||||
'estimated_ghs' => $estimated,
|
||||
'affordable' => $affordable,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{emailed: int, texted: int, skipped: int}
|
||||
*/
|
||||
public function send(QrCode $event, string $mode): array
|
||||
{
|
||||
$preview = $this->preview($event, $mode);
|
||||
abort_unless($preview['affordable'], 402, 'Insufficient wallet balance for this send.');
|
||||
|
||||
$programme = $this->programmeFor($event);
|
||||
$eventName = $event->content()['name'] ?? $event->label;
|
||||
$programmeUrl = $programme?->publicUrl();
|
||||
$joinUrl = $this->primaryJoinUrl($event);
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
|
||||
$emailed = 0;
|
||||
$texted = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($this->confirmedRegistrations($event) as $reg) {
|
||||
$sent = false;
|
||||
|
||||
if ($reg->attendee_email && in_array($mode, [self::MODE_PROGRAMME, self::MODE_BOTH], true) && $programmeUrl) {
|
||||
if ($mode === self::MODE_BOTH) {
|
||||
$sent = $this->email->sendJoinAndProgramme($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $programmeUrl, $reg->attendee_name);
|
||||
} else {
|
||||
$sent = $this->email->sendProgrammeShare($ownerRef, $reg->attendee_email, $eventName, $programmeUrl, $reg->attendee_name);
|
||||
}
|
||||
if ($sent) {
|
||||
$emailed++;
|
||||
}
|
||||
} elseif ($reg->attendee_email && $mode === self::MODE_JOIN && $joinUrl !== '') {
|
||||
$sent = $this->email->sendJoinLink($ownerRef, $reg->attendee_email, $eventName, $joinUrl, $reg->attendee_name);
|
||||
if ($sent) {
|
||||
$emailed++;
|
||||
}
|
||||
}
|
||||
|
||||
if ($reg->attendee_phone) {
|
||||
$message = $this->smsBody($reg, $eventName, $mode, $programmeUrl, $joinUrl);
|
||||
if ($message !== '') {
|
||||
$this->sms->send($ownerRef, $reg->attendee_phone, $message);
|
||||
$texted++;
|
||||
$sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $sent) {
|
||||
$skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return compact('emailed', 'texted', 'skipped');
|
||||
}
|
||||
|
||||
/** @return Collection<int, QrEventRegistration> */
|
||||
private function confirmedRegistrations(QrCode $event): Collection
|
||||
{
|
||||
return $event->eventRegistrations()
|
||||
->where('status', QrEventRegistration::STATUS_CONFIRMED)
|
||||
->get();
|
||||
}
|
||||
|
||||
private function programmeFor(QrCode $event): ?QrCode
|
||||
{
|
||||
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
|
||||
if ($programmeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return QrCode::where('id', $programmeId)
|
||||
->where('user_id', $event->user_id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
}
|
||||
|
||||
public function primaryJoinUrl(QrCode $event): string
|
||||
{
|
||||
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
$url = trim((string) ($session['join_url'] ?? ''));
|
||||
if ($url !== '') {
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function smsBody(QrEventRegistration $reg, string $eventName, string $mode, ?string $programmeUrl, string $joinUrl): string
|
||||
{
|
||||
$first = explode(' ', $reg->attendee_name ?? 'there')[0];
|
||||
|
||||
return match ($mode) {
|
||||
self::MODE_PROGRAMME => $programmeUrl
|
||||
? sprintf('Hi %s, the programme for %s: %s', $first, $eventName, $programmeUrl)
|
||||
: '',
|
||||
self::MODE_JOIN => $joinUrl !== ''
|
||||
? sprintf('Hi %s, join %s here: %s', $first, $eventName, $joinUrl)
|
||||
: '',
|
||||
self::MODE_BOTH => ($programmeUrl && $joinUrl !== '')
|
||||
? sprintf('Hi %s, %s — programme: %s | join: %s', $first, $eventName, $programmeUrl, $joinUrl)
|
||||
: ($joinUrl !== '' ? sprintf('Hi %s, join %s: %s', $first, $eventName, $joinUrl) : ''),
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function estimateSmsSegments(QrCode $event, string $mode): int
|
||||
{
|
||||
$sample = $this->smsBody(
|
||||
new QrEventRegistration(['attendee_name' => 'Guest']),
|
||||
$event->content()['name'] ?? $event->label,
|
||||
$mode,
|
||||
$this->programmeFor($event)?->publicUrl(),
|
||||
$this->primaryJoinUrl($event),
|
||||
);
|
||||
|
||||
return max(1, (int) ceil(strlen($sample) / self::SMS_SEGMENT_CHARS));
|
||||
}
|
||||
}
|
||||
@@ -16,16 +16,74 @@ class EventEmailService
|
||||
string $programmeUrl,
|
||||
?string $attendeeName = null,
|
||||
): bool {
|
||||
$html = View::make('mail.notifications.event-programme', [
|
||||
'eventName' => $eventName,
|
||||
'programmeUrl' => $programmeUrl,
|
||||
'attendeeName' => $attendeeName,
|
||||
])->render();
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Programme for '.$eventName,
|
||||
'mail.notifications.event-programme',
|
||||
compact('eventName', 'programmeUrl', 'attendeeName'),
|
||||
);
|
||||
}
|
||||
|
||||
public function sendJoinLink(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $joinUrl,
|
||||
?string $attendeeName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Join '.$eventName,
|
||||
'mail.notifications.event-join',
|
||||
compact('eventName', 'joinUrl', 'attendeeName'),
|
||||
);
|
||||
}
|
||||
|
||||
public function sendJoinAndProgramme(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $joinUrl,
|
||||
string $programmeUrl,
|
||||
?string $attendeeName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
$eventName.' — programme & join link',
|
||||
'mail.notifications.event-join-programme',
|
||||
compact('eventName', 'joinUrl', 'programmeUrl', 'attendeeName'),
|
||||
);
|
||||
}
|
||||
|
||||
public function sendRegistrationConfirmation(
|
||||
string $ownerPublicId,
|
||||
string $to,
|
||||
string $eventName,
|
||||
string $badgeCode,
|
||||
?string $joinUrl = null,
|
||||
?string $attendeeName = null,
|
||||
): bool {
|
||||
return $this->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Registration confirmed — '.$eventName,
|
||||
'mail.notifications.event-registration-confirmed',
|
||||
compact('eventName', 'badgeCode', 'joinUrl', 'attendeeName'),
|
||||
);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $viewData */
|
||||
private function send(string $ownerPublicId, string $to, string $subject, string $view, array $viewData): bool
|
||||
{
|
||||
$html = View::make($view, $viewData)->render();
|
||||
|
||||
return $this->platform->send(
|
||||
$ownerPublicId,
|
||||
$to,
|
||||
'Programme for '.$eventName,
|
||||
$subject,
|
||||
$html,
|
||||
strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Events;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventProgrammeService
|
||||
{
|
||||
/** @return array{title: string, days: list<array<string, mixed>>}|null */
|
||||
public function snapshot(?QrCode $programme): ?array
|
||||
{
|
||||
if (! $programme || $programme->type !== QrCode::TYPE_ITINERARY) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$content = $programme->content();
|
||||
|
||||
return [
|
||||
'title' => (string) ($content['title'] ?? $programme->label),
|
||||
'subtitle' => (string) ($content['subtitle'] ?? ''),
|
||||
'location' => (string) ($content['location'] ?? ''),
|
||||
'days' => array_values((array) ($content['days'] ?? [])),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param list<string> $itemRefs */
|
||||
public function itemsForRefs(?array $snapshot, array $itemRefs): array
|
||||
{
|
||||
if (! $snapshot) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$refs = array_flip($itemRefs);
|
||||
$matched = [];
|
||||
|
||||
foreach ((array) ($snapshot['days'] ?? []) as $day) {
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
$ref = (string) ($item['ref'] ?? '');
|
||||
if ($ref !== '' && isset($refs[$ref])) {
|
||||
$matched[] = $item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $matched;
|
||||
}
|
||||
|
||||
/** @param list<array<string, mixed>> $items */
|
||||
public function hostsFromItems(array $items): array
|
||||
{
|
||||
return collect($items)
|
||||
->pluck('host')
|
||||
->filter(fn ($host) => is_string($host) && trim($host) !== '')
|
||||
->map(fn ($host) => trim($host))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @return Collection<int, QrCode> */
|
||||
public function eventsLinkedToProgramme(QrCode $programme): Collection
|
||||
{
|
||||
if ($programme->type !== QrCode::TYPE_ITINERARY) {
|
||||
return collect();
|
||||
}
|
||||
|
||||
return QrCode::query()
|
||||
->where('user_id', $programme->user_id)
|
||||
->where('type', QrCode::TYPE_EVENT)
|
||||
->get()
|
||||
->filter(fn (QrCode $event) => (int) ($event->content()['programme_qr_id'] ?? 0) === $programme->id);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ use App\Models\QrEventRegistration;
|
||||
use App\Services\Billing\BillingClient;
|
||||
use App\Services\Billing\PaystackService;
|
||||
use App\Services\Billing\SmsService;
|
||||
use App\Services\Events\EventEmailService;
|
||||
use App\Services\Meet\EventMeetAccessService;
|
||||
use App\Services\Pay\PayClient;
|
||||
use App\Support\LadillLink;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -19,6 +21,8 @@ class EventRegistrationService
|
||||
private PaystackService $paystack,
|
||||
private BillingClient $billing,
|
||||
private SmsService $sms,
|
||||
private EventEmailService $email,
|
||||
private EventMeetAccessService $meetAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -248,9 +252,7 @@ class EventRegistrationService
|
||||
|
||||
private function notifyConfirmed(QrEventRegistration $registration): void
|
||||
{
|
||||
if (! $registration->attendee_phone) {
|
||||
return;
|
||||
}
|
||||
$registration->loadMissing('qrCode.user');
|
||||
|
||||
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
|
||||
if ($ownerPublicId === '') {
|
||||
@@ -260,7 +262,20 @@ class EventRegistrationService
|
||||
$content = $registration->qrCode?->content() ?? [];
|
||||
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
|
||||
$firstName = explode(' ', $registration->attendee_name)[0];
|
||||
$joinUrl = $this->nextVirtualJoinUrl($content);
|
||||
|
||||
if ($registration->attendee_email) {
|
||||
$this->email->sendRegistrationConfirmation(
|
||||
$ownerPublicId,
|
||||
$registration->attendee_email,
|
||||
$eventName,
|
||||
$registration->badge_code,
|
||||
$joinUrl !== '' ? $joinUrl : null,
|
||||
$registration->attendee_name,
|
||||
);
|
||||
}
|
||||
|
||||
if ($registration->attendee_phone) {
|
||||
if (($content['mode'] ?? 'ticketing') === 'contributions') {
|
||||
$message = sprintf(
|
||||
'Hi %s, thank you for your %s of %s %s to %s. Ref: %s',
|
||||
@@ -281,7 +296,6 @@ class EventRegistrationService
|
||||
);
|
||||
}
|
||||
|
||||
$joinUrl = $this->nextVirtualJoinUrl($content);
|
||||
if ($joinUrl !== '') {
|
||||
$message .= ' Join: '.$joinUrl;
|
||||
}
|
||||
@@ -293,6 +307,9 @@ class EventRegistrationService
|
||||
);
|
||||
}
|
||||
|
||||
$this->meetAccess->syncRegistration($registration->fresh());
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $content */
|
||||
private function nextVirtualJoinUrl(array $content): string
|
||||
{
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\QrEventRegistration;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class EventMeetAccessService
|
||||
{
|
||||
public function syncRegistration(QrEventRegistration $registration): void
|
||||
{
|
||||
$event = $registration->qrCode;
|
||||
if (! $event || $event->type !== QrCode::TYPE_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
$format = (string) ($event->content()['format'] ?? 'in_person');
|
||||
if (! in_array($format, ['virtual', 'hybrid'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((string) config('meet.key', '') === '' || ! $registration->attendee_email) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ownerRef = (string) $event->user->public_id;
|
||||
$client = MeetClient::for($ownerRef);
|
||||
|
||||
foreach ((array) ($event->content()['virtual_sessions'] ?? []) as $session) {
|
||||
if (! is_array($session)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$roomUuid = trim((string) ($session['meet_room_uuid'] ?? ''));
|
||||
if ($roomUuid === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$client->updateRoom($roomUuid, [
|
||||
'invite_emails' => [$registration->attendee_email],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Events Meet invite failed', [
|
||||
'registration_id' => $registration->id,
|
||||
'room_uuid' => $roomUuid,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$metadata = (array) ($registration->metadata ?? []);
|
||||
$metadata['meet_invited_at'] = now()->toIso8601String();
|
||||
$registration->update(['metadata' => $metadata]);
|
||||
}
|
||||
|
||||
public function isJoinWindowOpen(array $session, int $minutesBefore = 15): bool
|
||||
{
|
||||
$scheduled = trim((string) ($session['scheduled_at'] ?? ''));
|
||||
if ($scheduled === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
$start = \Carbon\Carbon::parse($scheduled);
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$duration = max(15, (int) ($session['duration_minutes'] ?? 60));
|
||||
|
||||
return now()->between($start->copy()->subMinutes($minutesBefore), $start->copy()->addMinutes($duration));
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,14 @@ namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use App\Models\User;
|
||||
use App\Services\Events\EventProgrammeService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class EventMeetSyncService
|
||||
{
|
||||
public function __construct(private readonly EventProgrammeService $programmes) {}
|
||||
|
||||
public function sync(QrCode $event, User $owner): QrCode
|
||||
{
|
||||
if ($event->type !== QrCode::TYPE_EVENT) {
|
||||
@@ -28,6 +31,7 @@ class EventMeetSyncService
|
||||
return $event;
|
||||
}
|
||||
|
||||
$programmeSnapshot = $this->programmeSnapshotFor($event);
|
||||
$sessions = (array) ($content['virtual_sessions'] ?? []);
|
||||
$client = MeetClient::for($owner->public_id);
|
||||
$synced = [];
|
||||
@@ -56,6 +60,23 @@ class EventMeetSyncService
|
||||
: 'town_hall';
|
||||
|
||||
$scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null;
|
||||
$itemRefs = (array) ($session['programme_item_refs'] ?? []);
|
||||
$linkedItems = $this->programmes->itemsForRefs($programmeSnapshot, $itemRefs);
|
||||
$inviteEmails = $this->inviteEmailsFromHosts($this->programmes->hostsFromItems($linkedItems));
|
||||
|
||||
$settings = [
|
||||
'programme_snapshot' => $programmeSnapshot,
|
||||
'linked_programme_items' => $linkedItems,
|
||||
];
|
||||
|
||||
if ($roomType === 'town_hall' && $linkedItems !== []) {
|
||||
$settings['panels'] = [[
|
||||
'id' => 'panel-'.$sessionId,
|
||||
'name' => $title,
|
||||
'speaker_refs' => [],
|
||||
]];
|
||||
$settings['panel_discussions'] = true;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $client->upsertRoom([
|
||||
@@ -65,6 +86,8 @@ class EventMeetSyncService
|
||||
'scheduled_at' => $scheduledAt,
|
||||
'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)),
|
||||
'timezone' => config('app.timezone', 'UTC'),
|
||||
'settings' => $settings,
|
||||
'invite_emails' => $inviteEmails,
|
||||
'source' => [
|
||||
'app' => 'events',
|
||||
'entity_type' => 'virtual_session',
|
||||
@@ -101,6 +124,38 @@ class EventMeetSyncService
|
||||
return $event->fresh();
|
||||
}
|
||||
|
||||
public function syncEventsForProgramme(QrCode $programme, User $owner): void
|
||||
{
|
||||
foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) {
|
||||
$this->sync($event->fresh(), $owner);
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private function programmeSnapshotFor(QrCode $event): ?array
|
||||
{
|
||||
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
|
||||
if ($programmeId <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$programme = QrCode::where('id', $programmeId)
|
||||
->where('user_id', $event->user_id)
|
||||
->where('type', QrCode::TYPE_ITINERARY)
|
||||
->first();
|
||||
|
||||
return $this->programmes->snapshot($programme);
|
||||
}
|
||||
|
||||
/** @param list<string> $hosts */
|
||||
private function inviteEmailsFromHosts(array $hosts): array
|
||||
{
|
||||
return collect($hosts)
|
||||
->filter(fn ($host) => filter_var($host, FILTER_VALIDATE_EMAIL))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/** @param list<string> $keepIds */
|
||||
private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void
|
||||
{
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Meet;
|
||||
|
||||
use App\Models\QrCode;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class MeetLifecycleService
|
||||
{
|
||||
/** @param array<string, mixed> $payload */
|
||||
public function handle(string $event, array $payload): void
|
||||
{
|
||||
$room = (array) ($payload['room'] ?? []);
|
||||
$source = (array) ($room['source'] ?? []);
|
||||
|
||||
if (($source['app'] ?? '') !== 'events') {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventId = (int) ($source['event_id'] ?? 0);
|
||||
$sessionId = (string) ($source['entity_id'] ?? '');
|
||||
|
||||
if ($eventId <= 0 || $sessionId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$qrCode = QrCode::find($eventId);
|
||||
if (! $qrCode || $qrCode->type !== QrCode::TYPE_EVENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
$content = $qrCode->content();
|
||||
$sessions = (array) ($content['virtual_sessions'] ?? []);
|
||||
$updated = false;
|
||||
|
||||
foreach ($sessions as $index => $session) {
|
||||
if (! is_array($session) || ($session['id'] ?? '') !== $sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sessions[$index] = array_merge($session, $this->patchForEvent($event, $payload, $room));
|
||||
$updated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (! $updated) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payloadData = (array) ($qrCode->payload ?? []);
|
||||
$payloadData['content'] = array_merge($content, ['virtual_sessions' => array_values($sessions)]);
|
||||
$qrCode->update(['payload' => $payloadData]);
|
||||
|
||||
Log::info('Events Meet lifecycle applied', ['event' => $event, 'event_id' => $eventId, 'session_id' => $sessionId]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function patchForEvent(string $event, array $payload, array $room): array
|
||||
{
|
||||
return match ($event) {
|
||||
'meeting.started' => ['meet_status' => 'live', 'meet_live_at' => now()->toIso8601String()],
|
||||
'meeting.ended' => ['meet_status' => 'ended', 'meet_ended_at' => now()->toIso8601String()],
|
||||
'meeting.cancelled' => ['meet_status' => 'cancelled'],
|
||||
'recording.ready' => [
|
||||
'recording_url' => (string) (($payload['recording']['download_url'] ?? '') ?: ''),
|
||||
'recording_ready_at' => now()->toIso8601String(),
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -303,6 +303,10 @@ class QrCodeManagerService
|
||||
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
|
||||
}
|
||||
|
||||
if ($qrCode->type === QrCode::TYPE_ITINERARY) {
|
||||
$this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user);
|
||||
}
|
||||
|
||||
return $qrCode->fresh(['document']);
|
||||
}
|
||||
|
||||
|
||||
@@ -374,12 +374,12 @@ class QrPayloadValidator
|
||||
|
||||
// Days: [{ label, date, items: [{ time, title, description, location, host }] }]
|
||||
$days = [];
|
||||
foreach ((array) ($input['days'] ?? []) as $day) {
|
||||
foreach ((array) ($input['days'] ?? []) as $dayIndex => $day) {
|
||||
if (! is_array($day)) {
|
||||
continue;
|
||||
}
|
||||
$items = [];
|
||||
foreach ((array) ($day['items'] ?? []) as $item) {
|
||||
foreach ((array) ($day['items'] ?? []) as $itemIndex => $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
@@ -388,6 +388,7 @@ class QrPayloadValidator
|
||||
continue;
|
||||
}
|
||||
$items[] = [
|
||||
'ref' => 'day'.$dayIndex.'-item'.$itemIndex,
|
||||
'time' => mb_substr(trim((string) ($item['time'] ?? '')), 0, 40),
|
||||
'title' => mb_substr($itemTitle, 0, 140),
|
||||
'description' => mb_substr(trim((string) ($item['description'] ?? '')), 0, 400),
|
||||
|
||||
@@ -4,5 +4,6 @@ return [
|
||||
|
||||
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
|
||||
'key' => env('MEET_API_KEY_EVENTS'),
|
||||
'webhook_secret' => env('MEET_WEBHOOK_SECRET_EVENTS'),
|
||||
|
||||
];
|
||||
|
||||
@@ -32,42 +32,74 @@
|
||||
@endunless
|
||||
</div>
|
||||
|
||||
{{-- Share programme outline --}}
|
||||
@if($programme)
|
||||
<div class="flex flex-col gap-3 rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{{-- Mass comms --}}
|
||||
@if(!empty($commsModes))
|
||||
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 p-4"
|
||||
x-data="{
|
||||
mode: @js($defaultCommsMode),
|
||||
preview: null,
|
||||
busy: false,
|
||||
async loadPreview() {
|
||||
const r = await fetch(@js(route('events.attendees.comms-preview', $qrCode)).replace(/&/g, '&') + '?mode=' + encodeURIComponent(this.mode), {
|
||||
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
|
||||
});
|
||||
if (r.ok) this.preview = await r.json();
|
||||
},
|
||||
}"
|
||||
x-init="loadPreview()">
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-indigo-100 text-indigo-600">
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"/></svg>
|
||||
</span>
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-slate-900">Programme outline</p>
|
||||
<p class="text-xs text-slate-500">Email & text the <span class="font-medium text-slate-600">{{ $programme->label }}</span> programme link to all confirmed attendees.</p>
|
||||
<p class="text-sm font-semibold text-slate-900">Message attendees</p>
|
||||
<p class="text-xs text-slate-500">Send programme links, virtual join links, or both. SMS and email are billed from your wallet.</p>
|
||||
<template x-if="preview">
|
||||
<p class="mt-2 text-xs text-slate-600">
|
||||
<span x-text="preview.recipients"></span> recipients ·
|
||||
est. GHS <span x-text="preview.estimated_ghs.toFixed(2)"></span>
|
||||
<span x-show="!preview.affordable" class="font-semibold text-red-600"> — insufficient balance</span>
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<form method="POST" action="{{ route('events.attendees.share-programme', $qrCode) }}"
|
||||
x-data="{ busy: false }"
|
||||
<form method="POST" action="{{ route('events.attendees.share-comms', $qrCode) }}"
|
||||
@submit="async ($event) => {
|
||||
if (busy) return;
|
||||
if (! await $store.ladillConfirm.ask({
|
||||
title: 'Send programme?',
|
||||
message: 'Send the programme outline to all confirmed attendees by email and SMS?',
|
||||
title: 'Send messages?',
|
||||
message: 'Send to all confirmed attendees by email and SMS where available?',
|
||||
confirmLabel: 'Send',
|
||||
variant: 'primary',
|
||||
})) { $event.preventDefault(); return; }
|
||||
busy = true;
|
||||
}"
|
||||
class="shrink-0">
|
||||
class="flex w-full shrink-0 flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||
@csrf
|
||||
<button type="submit" :disabled="busy"
|
||||
<select name="mode" x-model="mode" @change="loadPreview()"
|
||||
class="rounded-xl border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700 focus:border-indigo-400 focus:outline-none">
|
||||
@if(in_array(\App\Services\Events\EventCommsService::MODE_PROGRAMME, $commsModes))
|
||||
<option value="programme">Programme only</option>
|
||||
@endif
|
||||
@if(in_array(\App\Services\Events\EventCommsService::MODE_JOIN, $commsModes))
|
||||
<option value="join">Join link only</option>
|
||||
@endif
|
||||
@if(in_array(\App\Services\Events\EventCommsService::MODE_BOTH, $commsModes))
|
||||
<option value="both">Programme & join link</option>
|
||||
@endif
|
||||
</select>
|
||||
<button type="submit" :disabled="busy || (preview && !preview.affordable)"
|
||||
class="btn-primary w-full sm:w-auto">
|
||||
<span x-text="busy ? 'Sending…' : 'Share with attendees'">Share with attendees</span>
|
||||
<span x-text="busy ? 'Sending…' : 'Send to attendees'">Send to attendees</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{-- Stats --}}
|
||||
<div class="grid {{ $isContribution ? 'grid-cols-2' : 'grid-cols-3' }} gap-3">
|
||||
<div class="grid gap-3 {{ isset($stats['virtual_sessions']) ? ($isContribution ? 'grid-cols-2 sm:grid-cols-4' : 'grid-cols-2 sm:grid-cols-5') : ($isContribution ? 'grid-cols-2' : 'grid-cols-3') }}">
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['total']) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Contributions' : 'Registered' }}</p>
|
||||
@@ -82,6 +114,16 @@
|
||||
<p class="text-2xl font-bold text-slate-900">GHS {{ number_format($stats['revenue'], 2) }}</p>
|
||||
<p class="text-xs text-slate-500">{{ $isContribution ? 'Total raised' : 'Revenue' }}</p>
|
||||
</div>
|
||||
@if(isset($stats['virtual_sessions']))
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['virtual_live']) }}/{{ number_format($stats['virtual_sessions']) }}</p>
|
||||
<p class="text-xs text-slate-500">Sessions live</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<p class="text-2xl font-bold text-slate-900">{{ number_format($stats['virtual_recordings']) }}</p>
|
||||
<p class="text-xs text-slate-500">Recordings ready</p>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Toolbar --}}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
|
||||
@if($attendeeName)
|
||||
<p>Hi {{ $attendeeName }},</p>
|
||||
@endif
|
||||
<p>Here is everything you need for <strong>{{ $eventName }}</strong>:</p>
|
||||
<ul>
|
||||
<li><a href="{{ $programmeUrl }}">View programme</a></li>
|
||||
<li><a href="{{ $joinUrl }}">Join virtual session</a></li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
|
||||
@if($attendeeName)
|
||||
<p>Hi {{ $attendeeName }},</p>
|
||||
@endif
|
||||
<p>Your virtual session for <strong>{{ $eventName }}</strong> is ready.</p>
|
||||
<p><a href="{{ $joinUrl }}">Join the session</a></p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: sans-serif; color: #1e293b; line-height: 1.5;">
|
||||
@if($attendeeName)
|
||||
<p>Hi {{ $attendeeName }},</p>
|
||||
@endif
|
||||
<p>You are registered for <strong>{{ $eventName }}</strong>.</p>
|
||||
<p>Your badge code: <strong>{{ $badgeCode }}</strong></p>
|
||||
@if(!empty($joinUrl))
|
||||
<p><a href="{{ $joinUrl }}">Join the virtual session</a></p>
|
||||
@endif
|
||||
</body>
|
||||
</html>
|
||||
@@ -713,20 +713,36 @@
|
||||
|
||||
@php
|
||||
$evFormat = $content['format'] ?? 'in_person';
|
||||
$evVirtualSessions = collect($content['virtual_sessions'] ?? [])->filter(fn ($s) => is_array($s) && ! empty($s['join_url']));
|
||||
$meetAccess = app(\App\Services\Meet\EventMeetAccessService::class);
|
||||
$evVirtualSessions = collect($content['virtual_sessions'] ?? [])
|
||||
->filter(fn ($s) => is_array($s) && ! empty($s['join_url']));
|
||||
@endphp
|
||||
@if(in_array($evFormat, ['virtual', 'hybrid'], true) && $evVirtualSessions->isNotEmpty())
|
||||
<div class="mt-6 space-y-3">
|
||||
<h2 class="text-center text-sm font-bold text-slate-900">Virtual sessions</h2>
|
||||
@foreach($evVirtualSessions as $session)
|
||||
@php $windowOpen = $meetAccess->isJoinWindowOpen($session); @endphp
|
||||
@if($windowOpen)
|
||||
<a href="{{ $session['join_url'] }}" target="_blank" rel="noopener"
|
||||
class="block rounded-2xl border border-indigo-100 bg-white px-4 py-3 shadow-sm transition hover:border-indigo-300">
|
||||
<p class="font-semibold text-slate-900">{{ $session['title'] ?? 'Join session' }}</p>
|
||||
@if(!empty($session['scheduled_at']))
|
||||
<p class="mt-1 text-xs text-slate-500">{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}</p>
|
||||
@endif
|
||||
@if(($session['meet_status'] ?? '') === 'live')
|
||||
<p class="mt-1 text-xs font-semibold text-emerald-600">Live now</p>
|
||||
@endif
|
||||
<p class="mt-2 text-xs font-semibold text-indigo-600">Join virtual session →</p>
|
||||
</a>
|
||||
@else
|
||||
<div class="block rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p class="font-semibold text-slate-700">{{ $session['title'] ?? 'Virtual session' }}</p>
|
||||
@if(!empty($session['scheduled_at']))
|
||||
<p class="mt-1 text-xs text-slate-500">{{ \App\Support\Qr\QrDateFormatter::forDisplay($session['scheduled_at']) }}</p>
|
||||
@endif
|
||||
<p class="mt-2 text-xs text-amber-700">Join opens 15 minutes before the session starts.</p>
|
||||
</div>
|
||||
@endif
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -8,6 +8,8 @@ use Illuminate\Support\Facades\Route;
|
||||
// Signed SSL completion callback from Ladill Domains (HMAC-verified, no session).
|
||||
Route::post('/ssl-callback', SslCallbackController::class)->name('api.ssl-callback');
|
||||
|
||||
Route::post('/meet/webhooks', \App\Http\Controllers\Api\MeetWebhookController::class)->name('api.meet.webhooks');
|
||||
|
||||
Route::middleware(['auth:sanctum', \App\Http\Middleware\SetActingAccount::class])->prefix('v1')->group(function () {
|
||||
Route::get('/me', MeController::class);
|
||||
|
||||
|
||||
@@ -85,6 +85,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
||||
Route::get('/events/{event}/badges.zpl', [AttendeeController::class, 'badgesZpl'])->name('events.badges.zpl');
|
||||
Route::patch('/events/{event}/attendees/{registration}/check-in', [AttendeeController::class, 'checkIn'])->name('events.attendees.check-in');
|
||||
Route::post('/events/{event}/attendees/share-programme', [AttendeeController::class, 'shareProgramme'])->name('events.attendees.share-programme');
|
||||
Route::get('/events/{event}/attendees/comms-preview', [AttendeeController::class, 'previewComms'])->name('events.attendees.comms-preview');
|
||||
Route::post('/events/{event}/attendees/share-comms', [AttendeeController::class, 'shareComms'])->name('events.attendees.share-comms');
|
||||
|
||||
Route::get('/programmes', [ProgrammeController::class, 'index'])->name('programmes.index');
|
||||
Route::get('/programmes/create', fn () => redirect()->route('events.create', ['type' => 'itinerary']))->name('programmes.create');
|
||||
|
||||
Reference in New Issue
Block a user