From fec24da7bd5b33e60305daeb8725dd7b9d4bb2d0 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Fri, 3 Jul 2026 15:48:32 +0000 Subject: [PATCH] Add speaker invitations with portal page and programme host picker. Speakers require email, get a personal holding page with programme and stage links, auto-invites when programme hosts are saved, and manual send for events without a schedule; also fix wallet copy on event create and anchor attendee comms. Co-authored-by: Cursor --- .../Api/V1/ServiceMeetController.php | 21 ++ .../Controllers/Events/SpeakerController.php | 39 ++- .../Events/SpeakerPortalController.php | 60 ++++ app/Http/Controllers/Qr/QrCodeController.php | 3 + .../RedirectLegacyQrToLadillLink.php | 3 +- app/Services/Events/EventEmailService.php | 20 ++ app/Services/Events/EventProgrammeService.php | 51 ++- .../Events/EventSpeakerInviteService.php | 322 ++++++++++++++++++ app/Services/Qr/QrCodeManagerService.php | 2 + app/Services/Qr/QrPayloadValidator.php | 80 ++++- resources/views/events/attendees.blade.php | 2 +- resources/views/events/create.blade.php | 2 +- .../views/events/speaker-portal.blade.php | 107 ++++++ resources/views/events/speakers.blade.php | 49 ++- .../event-speaker-invite.blade.php | 12 + .../partials/type-fields-edit.blade.php | 36 +- routes/api.php | 3 + routes/web.php | 6 +- tests/Feature/EventSpeakerInviteTest.php | 229 +++++++++++++ tests/Feature/EventSpeakerManagementTest.php | 4 +- 20 files changed, 1015 insertions(+), 36 deletions(-) create mode 100644 app/Http/Controllers/Events/SpeakerPortalController.php create mode 100644 app/Services/Events/EventSpeakerInviteService.php create mode 100644 resources/views/events/speaker-portal.blade.php create mode 100644 resources/views/mail/notifications/event-speaker-invite.blade.php create mode 100644 tests/Feature/EventSpeakerInviteTest.php diff --git a/app/Http/Controllers/Api/V1/ServiceMeetController.php b/app/Http/Controllers/Api/V1/ServiceMeetController.php index 08a6fee..fff86c0 100644 --- a/app/Http/Controllers/Api/V1/ServiceMeetController.php +++ b/app/Http/Controllers/Api/V1/ServiceMeetController.php @@ -67,4 +67,25 @@ class ServiceMeetController extends Controller return response()->json(['registration' => $result]); } + + public function verifySpeaker(Request $request, QrCode $event): JsonResponse + { + $validated = $request->validate([ + 'owner_ref' => ['required', 'string', 'max:64'], + 'speaker_token' => ['required', 'string', 'max:128'], + ]); + + $owner = User::query()->where('public_id', $validated['owner_ref'])->firstOrFail(); + abort_unless($event->user_id === $owner->id, 403); + abort_unless($event->type === QrCode::TYPE_EVENT, 404); + + $result = app(\App\Services\Events\EventSpeakerInviteService::class) + ->verifyToken($event, $validated['speaker_token']); + + if (! $result) { + return response()->json(['message' => 'Speaker invite not found.'], 404); + } + + return response()->json(['speaker' => $result]); + } } diff --git a/app/Http/Controllers/Events/SpeakerController.php b/app/Http/Controllers/Events/SpeakerController.php index 912a78b..d4c4be0 100644 --- a/app/Http/Controllers/Events/SpeakerController.php +++ b/app/Http/Controllers/Events/SpeakerController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Events; use App\Http\Controllers\Controller; use App\Models\QrCode; +use App\Services\Events\EventSpeakerInviteService; use App\Services\Events\EventSpeakerService; use App\Services\Qr\QrCodeManagerService; use Illuminate\Http\RedirectResponse; @@ -14,6 +15,7 @@ class SpeakerController extends Controller { public function __construct( private readonly EventSpeakerService $speakers, + private readonly EventSpeakerInviteService $invites, private readonly QrCodeManagerService $manager, ) {} @@ -40,11 +42,16 @@ class SpeakerController extends Controller $this->authorize('view', $event); abort_unless($event->type === QrCode::TYPE_EVENT, 404); + $roster = $this->invites->rosterWithInviteState($event); + return view('events.speakers', [ 'qrCode' => $event, - 'roster' => (array) ($event->content()['speakers'] ?? []), + 'roster' => $roster, 'programmeAssignments' => $this->speakers->programmeAssignments($event), 'programme' => $this->speakers->linkedProgramme($event), + 'canManualInvite' => collect($roster)->mapWithKeys(fn ($speaker) => [ + $speaker['email'] => $this->invites->canSendManualInvite($event, $speaker), + ])->all(), ]); } @@ -56,17 +63,39 @@ class SpeakerController extends Controller $validated = $request->validate([ 'speakers' => ['nullable', 'array'], 'speakers.*.name' => ['nullable', 'string', 'max:120'], - 'speakers.*.email' => ['nullable', 'email', 'max:190'], + 'speakers.*.email' => ['required_with:speakers.*.name', 'email', 'max:190'], 'speakers.*.role' => ['nullable', 'string', 'max:80'], 'speakers.*.bio' => ['nullable', 'string', 'max:500'], ]); - $this->manager->update($event, [ - 'speakers' => $validated['speakers'] ?? [], - ]); + try { + $this->manager->update($event, [ + 'speakers' => $validated['speakers'] ?? [], + ]); + } catch (\RuntimeException $e) { + return back()->withInput()->with('error', $e->getMessage()); + } return redirect() ->route('speakers.show', $event) ->with('success', 'Speaker roster saved.'); } + + public function sendInvite(Request $request, QrCode $event): RedirectResponse + { + $this->authorize('update', $event); + abort_unless($event->type === QrCode::TYPE_EVENT, 404); + + $validated = $request->validate([ + 'email' => ['required', 'email', 'max:190'], + ]); + + $sent = $this->invites->sendManualInvite($event, $event->user, $validated['email']); + + if (! $sent) { + return back()->with('error', 'Could not send invitation. Check the email address or whether this speaker was already invited from the programme.'); + } + + return back()->with('success', 'Speaker invitation sent.'); + } } diff --git a/app/Http/Controllers/Events/SpeakerPortalController.php b/app/Http/Controllers/Events/SpeakerPortalController.php new file mode 100644 index 0000000..f90059f --- /dev/null +++ b/app/Http/Controllers/Events/SpeakerPortalController.php @@ -0,0 +1,60 @@ +invites->resolvePortal($shortCode, $token); + abort_unless($resolved, 404); + + $event = $resolved['event']; + $speaker = $resolved['speaker']; + $content = $event->content(); + $programme = null; + $programmeSnapshot = null; + + $programmeId = (int) ($content['programme_qr_id'] ?? 0); + if ($programmeId > 0) { + $programme = \App\Models\QrCode::query() + ->where('id', $programmeId) + ->where('type', \App\Models\QrCode::TYPE_ITINERARY) + ->first(); + $programmeSnapshot = $this->programmes->snapshot($programme); + } + + $token = (string) ($speaker['invite_token'] ?? ''); + $virtualSessions = collect((array) ($content['virtual_sessions'] ?? [])) + ->filter(fn ($session) => is_array($session) && trim((string) ($session['join_url'] ?? '')) !== '') + ->map(function (array $session) use ($event, $token) { + return [ + 'title' => trim((string) ($session['title'] ?? 'Virtual session')), + 'type' => in_array($session['meet_room_type'] ?? '', ['webinar'], true) ? 'Webinar' : 'Conference', + 'scheduled_at' => $session['scheduled_at'] ?? null, + 'join_url' => $this->invites->speakerJoinUrl($event, $session, $token), + ]; + }) + ->values() + ->all(); + + return view('events.speaker-portal', [ + 'event' => $event, + 'speaker' => $speaker, + 'eventContent' => $content, + 'programmeSnapshot' => $programmeSnapshot, + 'assignments' => $this->invites->assignmentsForSpeaker($event, $speaker), + 'virtualSessions' => $virtualSessions, + ]); + } +} diff --git a/app/Http/Controllers/Qr/QrCodeController.php b/app/Http/Controllers/Qr/QrCodeController.php index 9715483..313b3f8 100644 --- a/app/Http/Controllers/Qr/QrCodeController.php +++ b/app/Http/Controllers/Qr/QrCodeController.php @@ -254,6 +254,9 @@ class QrCodeController extends Controller 'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(), 'customDomainServerIp' => config('customdomain.server_ip'), 'itineraryOptions' => $itineraryOptions, + 'linkedEventSpeakers' => $qrCode->type === QrCode::TYPE_ITINERARY + ? app(\App\Services\Events\EventProgrammeService::class)->speakerOptionsForProgramme($qrCode) + : [], ]); } diff --git a/app/Http/Middleware/RedirectLegacyQrToLadillLink.php b/app/Http/Middleware/RedirectLegacyQrToLadillLink.php index 9d668ba..d503be1 100644 --- a/app/Http/Middleware/RedirectLegacyQrToLadillLink.php +++ b/app/Http/Middleware/RedirectLegacyQrToLadillLink.php @@ -82,6 +82,7 @@ class RedirectLegacyQrToLadillLink return $suffix === 'register' || str_starts_with($suffix, 'register/') - || str_starts_with($suffix, 'registered/'); + || str_starts_with($suffix, 'registered/') + || str_starts_with($suffix, 'speaker/'); } } diff --git a/app/Services/Events/EventEmailService.php b/app/Services/Events/EventEmailService.php index 02937f6..6372fdd 100644 --- a/app/Services/Events/EventEmailService.php +++ b/app/Services/Events/EventEmailService.php @@ -75,6 +75,26 @@ class EventEmailService ); } + public function sendSpeakerInvite( + string $ownerPublicId, + string $to, + string $eventName, + string $portalUrl, + ?string $speakerName = null, + ): bool { + return $this->send( + $ownerPublicId, + $to, + 'Speaker invitation — '.$eventName, + 'mail.notifications.event-speaker-invite', + [ + 'eventName' => $eventName, + 'portalUrl' => $portalUrl, + 'speakerName' => $speakerName, + ], + ); + } + /** @param array $viewData */ private function send(string $ownerPublicId, string $to, string $subject, string $view, array $viewData): bool { diff --git a/app/Services/Events/EventProgrammeService.php b/app/Services/Events/EventProgrammeService.php index 140d186..d02423f 100644 --- a/app/Services/Events/EventProgrammeService.php +++ b/app/Services/Events/EventProgrammeService.php @@ -50,14 +50,59 @@ class EventProgrammeService public function hostsFromItems(array $items): array { return collect($items) - ->pluck('host') - ->filter(fn ($host) => is_string($host) && trim($host) !== '') - ->map(fn ($host) => trim($host)) + ->flatMap(function ($item) { + if (! is_array($item)) { + return []; + } + + $email = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + return [$email]; + } + + $host = trim((string) ($item['host'] ?? '')); + if ($host !== '' && filter_var($host, FILTER_VALIDATE_EMAIL)) { + return [$host]; + } + + if ($host !== '') { + return [$host]; + } + + return []; + }) ->unique() ->values() ->all(); } + /** @return list */ + public function speakerOptionsForProgramme(QrCode $programme): array + { + $options = []; + + foreach ($this->eventsLinkedToProgramme($programme) as $event) { + foreach ((array) ($event->content()['speakers'] ?? []) as $speaker) { + if (! is_array($speaker)) { + continue; + } + + $email = trim((string) ($speaker['email'] ?? '')); + $name = trim((string) ($speaker['name'] ?? '')); + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL) || $name === '') { + continue; + } + + $options[strtolower($email)] = [ + 'email' => $email, + 'name' => $name, + ]; + } + } + + return array_values($options); + } + /** @return Collection */ public function eventsLinkedToProgramme(QrCode $programme): Collection { diff --git a/app/Services/Events/EventSpeakerInviteService.php b/app/Services/Events/EventSpeakerInviteService.php new file mode 100644 index 0000000..8cd327b --- /dev/null +++ b/app/Services/Events/EventSpeakerInviteService.php @@ -0,0 +1,322 @@ +> */ + public function rosterWithInviteState(QrCode $event): array + { + return collect((array) ($event->content()['speakers'] ?? [])) + ->map(fn ($speaker) => $this->normalizeSpeakerRow(is_array($speaker) ? $speaker : [])) + ->filter(fn ($speaker) => ($speaker['name'] ?? '') !== '') + ->values() + ->all(); + } + + public function canSendManualInvite(QrCode $event, array $speaker): bool + { + if (($speaker['email'] ?? '') === '') { + return false; + } + + if (! $this->speakers->linkedProgramme($event)) { + return true; + } + + if (empty($speaker['invited_at'])) { + return true; + } + + return ($speaker['invite_source'] ?? '') === self::SOURCE_MANUAL; + } + + public function sendManualInvite(QrCode $event, User $owner, string $email): bool + { + $email = strtolower(trim($email)); + $speakers = $this->rosterWithInviteState($event); + $index = collect($speakers)->search(fn ($row) => strcasecmp((string) ($row['email'] ?? ''), $email) === 0); + + if ($index === false) { + return false; + } + + $speaker = $speakers[$index]; + if (! $this->canSendManualInvite($event, $speaker)) { + return false; + } + + return $this->deliverInvite($event, $owner, $speaker, self::SOURCE_MANUAL); + } + + public function inviteProgrammeHosts(QrCode $programme, User $owner): int + { + if ($programme->type !== QrCode::TYPE_ITINERARY) { + return 0; + } + + $sent = 0; + + foreach ($this->programmes->eventsLinkedToProgramme($programme) as $event) { + $sent += $this->inviteHostsForEvent($event->fresh(), $owner, $programme); + } + + return $sent; + } + + public function inviteHostsForEvent(QrCode $event, User $owner, ?QrCode $programme = null): int + { + $programme ??= $this->speakers->linkedProgramme($event); + if (! $programme) { + return 0; + } + + $hostEmails = $this->hostSpeakerEmailsFromProgramme($programme); + if ($hostEmails === []) { + return 0; + } + + $sent = 0; + $speakers = $this->rosterWithInviteState($event); + + foreach ($speakers as $speaker) { + $email = strtolower((string) ($speaker['email'] ?? '')); + if ($email === '' || ! in_array($email, $hostEmails, true)) { + continue; + } + + if (($speaker['invite_source'] ?? '') === self::SOURCE_PROGRAMME && ! empty($speaker['invited_at'])) { + continue; + } + + if ($this->deliverInvite($event, $owner, $speaker, self::SOURCE_PROGRAMME)) { + $sent++; + } + } + + return $sent; + } + + /** @return array{event: QrCode, speaker: array}|null */ + public function resolvePortal(string $shortCode, string $token): ?array + { + $token = trim($token); + if ($token === '') { + return null; + } + + $event = QrCode::query() + ->where('short_code', $shortCode) + ->where('type', QrCode::TYPE_EVENT) + ->where('is_active', true) + ->first(); + + if (! $event) { + return null; + } + + foreach ($this->rosterWithInviteState($event) as $speaker) { + if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) { + return ['event' => $event, 'speaker' => $speaker]; + } + } + + return null; + } + + /** @return array|null */ + public function verifyToken(QrCode $event, string $token): ?array + { + $token = trim($token); + if ($token === '') { + return null; + } + + foreach ($this->rosterWithInviteState($event) as $speaker) { + if (hash_equals((string) ($speaker['invite_token'] ?? ''), $token)) { + return [ + 'email' => $speaker['email'], + 'name' => $speaker['name'], + 'role' => $speaker['role'] ?? '', + ]; + } + } + + return null; + } + + public function portalUrl(QrCode $event, string $token): string + { + return route('qr.public.speaker.portal', [ + 'shortCode' => $event->short_code, + 'token' => $token, + ]); + } + + /** @param array $session */ + public function speakerJoinUrl(QrCode $event, array $session, string $token): ?string + { + $joinUrl = trim((string) ($session['join_url'] ?? '')); + if ($joinUrl === '') { + return null; + } + + $separator = str_contains($joinUrl, '?') ? '&' : '?'; + + return $joinUrl.$separator.'speaker='.urlencode($token); + } + + /** @return list */ + public function assignmentsForSpeaker(QrCode $event, array $speaker): array + { + $programme = $this->speakers->linkedProgramme($event); + $snapshot = $this->programmes->snapshot($programme); + if (! $snapshot) { + return []; + } + + $email = strtolower((string) ($speaker['email'] ?? '')); + $name = trim((string) ($speaker['name'] ?? '')); + $assignments = []; + + foreach ((array) ($snapshot['days'] ?? []) as $day) { + $dayLabel = trim((string) ($day['date'] ?? $day['label'] ?? '')); + foreach ((array) ($day['items'] ?? []) as $item) { + if (! is_array($item)) { + continue; + } + + $hostEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); + $hostName = trim((string) ($item['host'] ?? '')); + + $matches = ($email !== '' && $hostEmail === $email) + || ($name !== '' && strcasecmp($hostName, $name) === 0); + + if (! $matches) { + continue; + } + + $assignments[] = [ + 'session' => trim((string) ($item['title'] ?? '')), + 'day' => $dayLabel, + 'time' => trim((string) ($item['time'] ?? '')), + 'location' => trim((string) ($item['location'] ?? '')), + ]; + } + } + + return $assignments; + } + + /** @param array $speaker */ + private function deliverInvite(QrCode $event, User $owner, array $speaker, string $source): bool + { + $email = trim((string) ($speaker['email'] ?? '')); + if ($email === '') { + return false; + } + + $token = (string) ($speaker['invite_token'] ?? ''); + if ($token === '') { + $token = Str::random(48); + } + + $eventName = (string) ($event->content()['name'] ?? $event->label); + $portalUrl = $this->portalUrl($event, $token); + + $sent = $this->email->sendSpeakerInvite( + $owner->public_id, + $email, + $eventName, + $portalUrl, + $speaker['name'] ?? null, + ); + + if (! $sent) { + return false; + } + + $this->persistSpeakerInviteMeta($event, $email, $token, $source); + + return true; + } + + private function persistSpeakerInviteMeta(QrCode $event, string $email, string $token, string $source): void + { + $email = strtolower(trim($email)); + $content = $event->content(); + $speakers = []; + + foreach ((array) ($content['speakers'] ?? []) as $row) { + if (! is_array($row)) { + continue; + } + + $rowEmail = strtolower(trim((string) ($row['email'] ?? ''))); + if ($rowEmail === $email) { + $row['invite_token'] = $token; + $row['invited_at'] = now()->toIso8601String(); + $row['invite_source'] = $source; + } + + $speakers[] = $row; + } + + $payload = (array) ($event->payload ?? []); + $payload['content'] = array_merge($content, ['speakers' => $speakers]); + $event->payload = $payload; + $event->save(); + } + + /** @return list */ + private function hostSpeakerEmailsFromProgramme(QrCode $programme): array + { + $emails = []; + + foreach ((array) ($programme->content()['days'] ?? []) as $day) { + foreach ((array) ($day['items'] ?? []) as $item) { + if (! is_array($item)) { + continue; + } + + $email = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); + if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) { + $emails[] = $email; + } + } + } + + return array_values(array_unique($emails)); + } + + /** @param array $speaker */ + private function normalizeSpeakerRow(array $speaker): array + { + $name = trim((string) ($speaker['name'] ?? '')); + $email = trim((string) ($speaker['email'] ?? '')); + + return [ + 'name' => $name, + 'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '', + 'role' => trim((string) ($speaker['role'] ?? '')), + 'bio' => trim((string) ($speaker['bio'] ?? '')), + 'invite_token' => trim((string) ($speaker['invite_token'] ?? '')), + 'invited_at' => $speaker['invited_at'] ?? null, + 'invite_source' => $speaker['invite_source'] ?? null, + ]; + } +} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index fbdf1d8..95a7777 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -309,6 +309,8 @@ class QrCodeManagerService if ($qrCode->type === QrCode::TYPE_ITINERARY) { $this->meetSync->syncEventsForProgramme($qrCode->fresh(), $qrCode->user); + app(\App\Services\Events\EventSpeakerInviteService::class) + ->inviteProgrammeHosts($qrCode->fresh(), $qrCode->user); } return $qrCode->fresh(['document']); diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php index 7fe32d4..361cad5 100644 --- a/app/Services/Qr/QrPayloadValidator.php +++ b/app/Services/Qr/QrPayloadValidator.php @@ -51,7 +51,16 @@ class QrPayloadValidator $merged['destination_url'] = $merged['url']; } - return $this->validateForCreate($qrCode->type, $merged); + $result = $this->validateForCreate($qrCode->type, $merged); + + if ($qrCode->type === QrCode::TYPE_EVENT && isset($result['content']['speakers'])) { + $result['content']['speakers'] = $this->mergeSpeakerInviteMeta( + (array) ($qrCode->content()['speakers'] ?? []), + $result['content']['speakers'], + ); + } + + return $result; } /** @param array $input */ @@ -354,7 +363,7 @@ class QrPayloadValidator return array_values($normalized); } - /** @return list */ + /** @return list */ private function normalizeSpeakers(mixed $speakers, array $input): array { $rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []); @@ -371,17 +380,57 @@ class QrPayloadValidator } $email = trim((string) ($speaker['email'] ?? '')); - $normalized[] = [ + if ($email === '' || ! filter_var($email, FILTER_VALIDATE_EMAIL)) { + throw new RuntimeException('Each speaker must have a valid email address.'); + } + + $row = [ 'name' => mb_substr($name, 0, 120), - 'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '', + 'email' => $email, 'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80), 'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500), ]; + + foreach (['invite_token', 'invited_at', 'invite_source'] as $key) { + if (! empty($speaker[$key])) { + $row[$key] = $speaker[$key]; + } + } + + $normalized[] = $row; } return array_values($normalized); } + /** + * @param list> $previous + * @param list> $current + * @return list> + */ + private function mergeSpeakerInviteMeta(array $previous, array $current): array + { + $byEmail = collect($previous) + ->filter(fn ($row) => is_array($row) && filled($row['email'] ?? null)) + ->keyBy(fn ($row) => strtolower((string) $row['email'])); + + return collect($current)->map(function (array $speaker) use ($byEmail) { + $email = strtolower((string) ($speaker['email'] ?? '')); + $prev = $byEmail->get($email); + if (! $prev) { + return $speaker; + } + + foreach (['invite_token', 'invited_at', 'invite_source'] as $key) { + if (! empty($prev[$key]) && empty($speaker[$key])) { + $speaker[$key] = $prev[$key]; + } + } + + return $speaker; + })->values()->all(); + } + /** @param array $tiers */ private function eventHasPaidTier(array $tiers): bool { @@ -417,13 +466,24 @@ class QrPayloadValidator if ($itemTitle === '') { continue; } + $hostSpeakerEmail = strtolower(trim((string) ($item['host_speaker_email'] ?? ''))); + if ($hostSpeakerEmail !== '' && ! filter_var($hostSpeakerEmail, FILTER_VALIDATE_EMAIL)) { + $hostSpeakerEmail = ''; + } + + $host = mb_substr(trim((string) ($item['host'] ?? '')), 0, 120); + if ($host === '' && $hostSpeakerEmail !== '') { + $host = mb_substr(strtok($hostSpeakerEmail, '@') ?: $hostSpeakerEmail, 0, 120); + } + $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), - 'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120), - 'host' => mb_substr(trim((string) ($item['host'] ?? '')), 0, 120), + '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), + 'location' => mb_substr(trim((string) ($item['location'] ?? '')), 0, 120), + 'host' => $host, + 'host_speaker_email' => $hostSpeakerEmail, ]; } if (empty($items) && trim((string) ($day['label'] ?? '')) === '') { diff --git a/resources/views/events/attendees.blade.php b/resources/views/events/attendees.blade.php index b4f78bb..46d1fee 100644 --- a/resources/views/events/attendees.blade.php +++ b/resources/views/events/attendees.blade.php @@ -34,7 +34,7 @@ {{-- Mass comms --}} @if(!empty($commsModes)) -
- GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance when you create this event. + GHS {{ number_format($pricePerQr, 2) }} will be charged from your wallet when you create this event.

diff --git a/resources/views/events/speaker-portal.blade.php b/resources/views/events/speaker-portal.blade.php new file mode 100644 index 0000000..26673d1 --- /dev/null +++ b/resources/views/events/speaker-portal.blade.php @@ -0,0 +1,107 @@ + + + + + + Speaker — {{ $eventContent['name'] ?? $event->label }} + @include('partials.favicon') + @vite(['resources/css/app.css']) + + +@php + $eventName = $eventContent['name'] ?? $event->label; + $brandColor = $programmeSnapshot['brand_color'] ?? '#4f46e5'; +@endphp + +
+
+

Speaker access

+

{{ $eventName }}

+

Hi {{ $speaker['name'] }}@if(!empty($speaker['role'])) · {{ $speaker['role'] }}@endif

+
+ + @if(!empty($eventContent['starts_at']) || !empty($eventContent['location'])) +
+ @if(!empty($eventContent['starts_at'])) +

When: {{ $eventContent['starts_at'] }}

+ @endif + @if(!empty($eventContent['location'])) +

Where: {{ $eventContent['location'] }}

+ @endif +
+ @endif + + @if(count($assignments)) +
+

Your sessions

+
    + @foreach($assignments as $assignment) +
  • +

    {{ $assignment['session'] ?: 'Session' }}

    +

    + @if($assignment['time']){{ $assignment['time'] }}@endif + @if($assignment['day']) · {{ $assignment['day'] }}@endif + @if($assignment['location']) · {{ $assignment['location'] }}@endif +

    +
  • + @endforeach +
+
+ @endif + + @if(count($virtualSessions)) +
+

Join virtual stage

+

Use these links when it is time to go live. No attendee registration required — you will enter the speaker waiting area.

+
+ @foreach($virtualSessions as $session) + + + {{ $session['title'] }} + {{ $session['type'] }} + + + + @endforeach +
+
+ @endif + + @if($programmeSnapshot) +
+

{{ $programmeSnapshot['title'] ?? 'Programme' }}

+ @if(!empty($programmeSnapshot['subtitle'])) +

{{ $programmeSnapshot['subtitle'] }}

+ @endif +
+ @foreach($programmeSnapshot['days'] ?? [] as $day) +
+

{{ $day['label'] ?? 'Day' }}@if(!empty($day['date'])) · {{ $day['date'] }}@endif

+
    + @foreach($day['items'] ?? [] as $item) +
  • +
    + @if(!empty($item['time'])) + {{ $item['time'] }} + @endif +
    +

    {{ $item['title'] ?? '' }}

    + @if(!empty($item['host'])) +

    {{ $item['host'] }}

    + @endif +
    +
    +
  • + @endforeach +
+
+ @endforeach +
+
+ @endif + +

Bookmark this page — it has everything you need on event day.

+
+ + diff --git a/resources/views/events/speakers.blade.php b/resources/views/events/speakers.blade.php index edfdaa2..04915a0 100644 --- a/resources/views/events/speakers.blade.php +++ b/resources/views/events/speakers.blade.php @@ -22,11 +22,12 @@ @if($programme)
Linked programme: {{ $programme->label }}. - Hosts assigned on programme sessions appear below and sync to virtual sessions. + Assign hosts from this speaker list on programme sessions — invitations are sent automatically when you save the programme.
@else
- Attach a programme on the event settings page to pull session hosts into this roster. + Add speakers here first, then attach a programme on event settings to schedule sessions and auto-invite hosts. + Without a programme, use Send invitation below for each speaker.
@endif @@ -42,7 +43,7 @@

Event speaker roster

-

Add keynote speakers, panelists, and hosts. Use email when you want to invite them to virtual sessions.

+

Every speaker needs an email. They are available when building the programme schedule.

@@ -51,9 +52,9 @@