diff --git a/.env.example b/.env.example index 9c106e8..d2f4f4a 100644 --- a/.env.example +++ b/.env.example @@ -39,6 +39,18 @@ PAY_API_KEY_EVENTS= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_EVENTS= +MEET_API_URL=https://meet.ladill.com/api/service/v1 +MEET_API_KEY_EVENTS= + +SMS_PLATFORM_API_URL=https://ladill.com/api +SMS_API_KEY_EVENTS= +SMS_DEFAULT_SENDER_ID=Ladill + +SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp +SMTP_API_KEY_EVENTS= +EVENTS_SMTP_FROM=events@ladill.com +EVENTS_SMTP_FROM_NAME="Ladill Events" + AFIA_ENABLED=true AFIA_PRODUCT=events AFIA_PROVIDER=openai diff --git a/app/Http/Controllers/Events/AttendeeController.php b/app/Http/Controllers/Events/AttendeeController.php index f8ed066..6ba97a3 100644 --- a/app/Http/Controllers/Events/AttendeeController.php +++ b/app/Http/Controllers/Events/AttendeeController.php @@ -5,18 +5,20 @@ namespace App\Http\Controllers\Events; use App\Http\Controllers\Controller; use App\Models\QrCode; use App\Models\QrEventRegistration; -use App\Notifications\EventProgrammeSharedNotification; use App\Services\Billing\SmsService; +use App\Services\Events\EventEmailService; use App\Support\Events\EventBadgeZpl; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Http\Response; -use Illuminate\Support\Facades\Notification; use Illuminate\View\View; class AttendeeController extends Controller { - public function __construct(private readonly SmsService $sms) {} + public function __construct( + private readonly SmsService $sms, + private readonly EventEmailService $email, + ) {} public function hub(Request $request): View { @@ -163,13 +165,19 @@ class AttendeeController extends Controller foreach ($recipients as $reg) { if ($reg->attendee_email) { - Notification::route('mail', $reg->attendee_email) - ->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name)); + $this->email->sendProgrammeShare( + (string) $event->user->public_id, + $reg->attendee_email, + $eventName, + $programmeUrl, + $reg->attendee_name, + ); $emailed++; } 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', diff --git a/app/Models/QrSetting.php b/app/Models/QrSetting.php index 8e35010..7bbbcc1 100644 --- a/app/Models/QrSetting.php +++ b/app/Models/QrSetting.php @@ -42,6 +42,7 @@ class QrSetting extends Model 'organizer', 'registration_open', 'badge_fields', + 'format', ]; } @@ -142,6 +143,9 @@ class QrSetting extends Model 'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120), 'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL), 'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'], + 'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true) + ? ($input['format'] ?? 'in_person') + : 'in_person', ]; } diff --git a/app/Services/Billing/PlatformEmailClient.php b/app/Services/Billing/PlatformEmailClient.php new file mode 100644 index 0000000..080b7f8 --- /dev/null +++ b/app/Services/Billing/PlatformEmailClient.php @@ -0,0 +1,57 @@ +token() === '') { + return false; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(30) + ->post($this->base().'/messages/send', array_filter([ + 'user' => $ownerPublicId, + 'from' => config('smtp.from'), + 'from_name' => config('smtp.from_name'), + 'to' => $to, + 'subject' => $subject, + 'html' => $html, + 'text' => $text, + ])); + + if ($res->status() === 402) { + Log::warning('Platform email send: insufficient balance', ['user' => $ownerPublicId]); + + return false; + } + + if ($res->failed()) { + Log::warning('Platform email send failed', ['status' => $res->status(), 'body' => $res->body()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + Log::warning('Platform email send error', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Billing/PlatformSmsClient.php b/app/Services/Billing/PlatformSmsClient.php new file mode 100644 index 0000000..67b0356 --- /dev/null +++ b/app/Services/Billing/PlatformSmsClient.php @@ -0,0 +1,114 @@ +> */ + public function services(string $ownerPublicId): array + { + if ($this->token() === '') { + return []; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(15) + ->get($this->base().'/sms/services', ['user' => $ownerPublicId]); + + if ($res->failed()) { + return []; + } + + return (array) ($res->json('data') ?? []); + } catch (\Throwable $e) { + Log::warning('Platform SMS services lookup failed', ['error' => $e->getMessage()]); + + return []; + } + } + + public function ensureServiceId(string $ownerPublicId): ?int + { + $services = $this->services($ownerPublicId); + if ($services !== []) { + return (int) ($services[0]['id'] ?? 0) ?: null; + } + + if ($this->token() === '') { + return null; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(15) + ->post($this->base().'/sms/services', [ + 'user' => $ownerPublicId, + 'name' => 'Ladill Events', + 'brand_name' => 'Events', + ]); + + if ($res->failed()) { + return null; + } + + return (int) ($res->json('data.id') ?? 0) ?: null; + } catch (\Throwable $e) { + Log::warning('Platform SMS service create failed', ['error' => $e->getMessage()]); + + return null; + } + } + + public function send(string $ownerPublicId, string $to, string $message, ?string $senderId = null): bool + { + if ($this->token() === '') { + return false; + } + + $serviceId = $this->ensureServiceId($ownerPublicId); + if (! $serviceId) { + return false; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(30) + ->post($this->base().'/sms/messages/send', array_filter([ + 'user' => $ownerPublicId, + 'sms_service_id' => $serviceId, + 'to' => $to, + 'text' => $message, + 'sender_id' => $senderId ?? config('sms.default_sender_id'), + ])); + + if ($res->status() === 402) { + Log::warning('Platform SMS send: insufficient balance', ['user' => $ownerPublicId]); + + return false; + } + + if ($res->failed()) { + Log::warning('Platform SMS send failed', ['status' => $res->status(), 'body' => $res->body()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + Log::warning('Platform SMS send error', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Billing/SmsService.php b/app/Services/Billing/SmsService.php index 8be3d00..acfa99c 100644 --- a/app/Services/Billing/SmsService.php +++ b/app/Services/Billing/SmsService.php @@ -2,20 +2,12 @@ namespace App\Services\Billing; -use Illuminate\Support\Facades\Http; -use Illuminate\Support\Facades\Log; - class SmsService { - public function send(string $to, string $message): void + public function __construct(private readonly PlatformSmsClient $platform) {} + + public function send(string $ownerPublicId, string $to, string $message): void { - $apiKey = config('services.termii.api_key', ''); - $senderId = config('services.termii.sender_id', config('app.name', 'LaDill')); - - if ($apiKey === '') { - return; - } - $phone = preg_replace('/\D+/', '', $to); if (strlen($phone) < 9) { return; @@ -27,17 +19,6 @@ class SmsService $phone = '233'.substr($phone, 1); } - try { - Http::timeout(10)->post('https://v3.api.termii.com/api/sms/send', [ - 'api_key' => $apiKey, - 'to' => $phone, - 'from' => $senderId, - 'sms' => $message, - 'type' => 'plain', - 'channel' => 'dnd', - ]); - } catch (\Throwable $e) { - Log::warning('SMS send failed', ['to' => $phone, 'error' => $e->getMessage()]); - } + $this->platform->send($ownerPublicId, $phone, $message); } } diff --git a/app/Services/Events/EventEmailService.php b/app/Services/Events/EventEmailService.php new file mode 100644 index 0000000..e9c7d0d --- /dev/null +++ b/app/Services/Events/EventEmailService.php @@ -0,0 +1,33 @@ + $eventName, + 'programmeUrl' => $programmeUrl, + 'attendeeName' => $attendeeName, + ])->render(); + + return $this->platform->send( + $ownerPublicId, + $to, + 'Programme for '.$eventName, + $html, + strip_tags(str_replace(['
', '
', '
'], "\n", $html)), + ); + } +} diff --git a/app/Services/Events/EventRegistrationService.php b/app/Services/Events/EventRegistrationService.php index 6502a8f..f1c2c0b 100644 --- a/app/Services/Events/EventRegistrationService.php +++ b/app/Services/Events/EventRegistrationService.php @@ -252,6 +252,11 @@ class EventRegistrationService return; } + $ownerPublicId = (string) $registration->qrCode?->user?->public_id; + if ($ownerPublicId === '') { + return; + } + $content = $registration->qrCode?->content() ?? []; $eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name'); $firstName = explode(' ', $registration->attendee_name)[0]; @@ -276,7 +281,38 @@ class EventRegistrationService ); } - $this->sms->send($registration->attendee_phone, $message); + $joinUrl = $this->nextVirtualJoinUrl($content); + if ($joinUrl !== '') { + $message .= ' Join: '.$joinUrl; + } + + $this->sms->send( + $ownerPublicId, + $registration->attendee_phone, + $message, + ); + } + + /** @param array $content */ + private function nextVirtualJoinUrl(array $content): string + { + $format = (string) ($content['format'] ?? 'in_person'); + if (! in_array($format, ['virtual', 'hybrid'], true)) { + return ''; + } + + foreach ((array) ($content['virtual_sessions'] ?? []) as $session) { + if (! is_array($session)) { + continue; + } + + $joinUrl = trim((string) ($session['join_url'] ?? '')); + if ($joinUrl !== '') { + return $joinUrl; + } + } + + return ''; } private function uniqueBadgeCode(): string diff --git a/app/Services/Meet/EventMeetSyncService.php b/app/Services/Meet/EventMeetSyncService.php new file mode 100644 index 0000000..4580b79 --- /dev/null +++ b/app/Services/Meet/EventMeetSyncService.php @@ -0,0 +1,137 @@ +type !== QrCode::TYPE_EVENT) { + return $event; + } + + $content = $event->content(); + $format = (string) ($content['format'] ?? 'in_person'); + + if (! in_array($format, ['virtual', 'hybrid'], true)) { + $this->cancelRemovedSessions($event, $owner, []); + + return $event; + } + + if ((string) config('meet.key', '') === '') { + return $event; + } + + $sessions = (array) ($content['virtual_sessions'] ?? []); + $client = MeetClient::for($owner->public_id); + $synced = []; + $activeIds = []; + + foreach ($sessions as $session) { + if (! is_array($session)) { + continue; + } + + $sessionId = trim((string) ($session['id'] ?? '')); + if ($sessionId === '') { + $sessionId = 'vs-'.Str::lower(Str::random(12)); + } + + $activeIds[] = $sessionId; + $title = trim((string) ($session['title'] ?? '')); + if ($title === '') { + $synced[] = array_merge($session, ['id' => $sessionId]); + + continue; + } + + $roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true) + ? (string) $session['meet_room_type'] + : 'town_hall'; + + $scheduledAt = $session['scheduled_at'] ?? $content['starts_at'] ?? null; + + try { + $result = $client->upsertRoom([ + 'title' => $title, + 'description' => trim((string) ($content['description'] ?? '')) ?: null, + 'type' => $roomType, + 'scheduled_at' => $scheduledAt, + 'duration_minutes' => max(15, (int) ($session['duration_minutes'] ?? 60)), + 'timezone' => config('app.timezone', 'UTC'), + 'source' => [ + 'app' => 'events', + 'entity_type' => 'virtual_session', + 'entity_id' => $sessionId, + 'event_id' => (string) $event->id, + ], + ]); + } catch (\Throwable $e) { + Log::warning('Events Meet sync failed', [ + 'event_id' => $event->id, + 'session_id' => $sessionId, + 'error' => $e->getMessage(), + ]); + $synced[] = array_merge($session, ['id' => $sessionId]); + + continue; + } + + $room = (array) ($result['room'] ?? []); + $synced[] = array_merge($session, [ + 'id' => $sessionId, + 'meet_room_uuid' => $room['uuid'] ?? $session['meet_room_uuid'] ?? null, + 'join_url' => $result['join_url'] ?? $session['join_url'] ?? null, + ]); + } + + $this->cancelRemovedSessions($event, $owner, $activeIds); + + $payload = (array) ($event->payload ?? []); + $payload['content'] = array_merge($content, ['virtual_sessions' => array_values($synced)]); + $event->payload = $payload; + $event->save(); + + return $event->fresh(); + } + + /** @param list $keepIds */ + private function cancelRemovedSessions(QrCode $event, User $owner, array $keepIds): void + { + if ((string) config('meet.key', '') === '') { + return; + } + + $previous = (array) ($event->content()['virtual_sessions'] ?? []); + $client = MeetClient::for($owner->public_id); + + foreach ($previous as $session) { + if (! is_array($session)) { + continue; + } + + $sessionId = (string) ($session['id'] ?? ''); + $roomUuid = (string) ($session['meet_room_uuid'] ?? ''); + + if ($sessionId === '' || in_array($sessionId, $keepIds, true) || $roomUuid === '') { + continue; + } + + try { + $client->cancelRoom($roomUuid); + } catch (\Throwable $e) { + Log::warning('Events Meet cancel failed', [ + 'event_id' => $event->id, + 'session_id' => $sessionId, + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/app/Services/Meet/MeetClient.php b/app/Services/Meet/MeetClient.php new file mode 100644 index 0000000..212c51d --- /dev/null +++ b/app/Services/Meet/MeetClient.php @@ -0,0 +1,122 @@ +|null + */ + public function findBySource(string $app, string $entityId): ?array + { + try { + $response = $this->client()->get('rooms/by-source', [ + 'source_app' => $app, + 'entity_id' => $entityId, + 'owner_ref' => $this->ownerRef, + ]); + } catch (ConnectionException) { + return null; + } + + if ($response->status() === 404) { + return null; + } + + if ($response->failed()) { + return null; + } + + return (array) $response->json(); + } + + /** + * @param array $data + * @return array + */ + public function upsertRoom(array $data): array + { + return $this->post('rooms', array_merge($data, [ + 'owner_ref' => $this->ownerRef, + 'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef, + ])); + } + + /** + * @param array $data + * @return array + */ + public function updateRoom(string $roomUuid, array $data): array + { + return $this->patch("rooms/{$roomUuid}", array_merge($data, [ + 'owner_ref' => $this->ownerRef, + 'host_user_ref' => $data['host_user_ref'] ?? $this->ownerRef, + ])); + } + + public function cancelRoom(string $roomUuid, ?string $hostUserRef = null): array + { + return $this->post("rooms/{$roomUuid}/cancel", [ + 'owner_ref' => $this->ownerRef, + 'host_user_ref' => $hostUserRef ?? $this->ownerRef, + ]); + } + + private function client(): PendingRequest + { + return Http::baseUrl((string) config('meet.url')) + ->withToken((string) config('meet.key')) + ->acceptJson() + ->asJson() + ->connectTimeout(10) + ->timeout(20); + } + + /** + * @return array + */ + private function post(string $path, array $data): array + { + return $this->request('post', $path, $data); + } + + /** + * @return array + */ + private function patch(string $path, array $data): array + { + return $this->request('patch', $path, $data); + } + + /** + * @return array + */ + private function request(string $method, string $path, array $data): array + { + try { + $response = $this->client()->{$method}($path, $data); + } catch (ConnectionException) { + throw new \RuntimeException('Could not reach the Meet service.'); + } + + if ($response->failed()) { + throw new \RuntimeException('Meet service error: '.($response->json('message') ?? $response->body())); + } + + return (array) $response->json(); + } +} diff --git a/app/Services/Qr/QrCodeManagerService.php b/app/Services/Qr/QrCodeManagerService.php index 87ce022..50b96ae 100644 --- a/app/Services/Qr/QrCodeManagerService.php +++ b/app/Services/Qr/QrCodeManagerService.php @@ -19,6 +19,7 @@ class QrCodeManagerService private QrWalletBillingService $billing, private QrImageGeneratorService $imageGenerator, private QrPayloadValidator $payloadValidator, + private \App\Services\Meet\EventMeetSyncService $meetSync, ) {} public function walletFor(User $user): QrWallet @@ -158,6 +159,10 @@ class QrCodeManagerService $this->billing->debitForQrCreation($wallet, $qrCode); $this->imageGenerator->generateAndStore($qrCode); + if ($type === QrCode::TYPE_EVENT) { + $qrCode = $this->meetSync->sync($qrCode->fresh(), $user); + } + return $qrCode->fresh(['document']); }); } @@ -294,6 +299,10 @@ class QrCodeManagerService $this->imageGenerator->generateAndStore($qrCode); } + if ($qrCode->type === QrCode::TYPE_EVENT) { + $qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user); + } + return $qrCode->fresh(['document']); } @@ -347,7 +356,7 @@ class QrCodeManagerService QrCode::TYPE_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'], QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'], QrCode::TYPE_CHURCH => ['name', 'denomination', 'description', 'phone', 'email', 'website', 'address', 'service_times', 'org_type', 'accepts_payment', 'collection_types', 'brand_color'], - QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open'], + QrCode::TYPE_EVENT => ['name', 'tagline', 'description', 'location', 'starts_at', 'ends_at', 'organizer', 'website', 'brand_color', 'tiers', 'badge_fields', 'badge_size', 'registration_open', 'format', 'virtual_sessions'], QrCode::TYPE_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'], QrCode::TYPE_DOCUMENT => ['allow_download'], QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'], diff --git a/app/Services/Qr/QrPayloadValidator.php b/app/Services/Qr/QrPayloadValidator.php index d7a1f85..f3af4dd 100644 --- a/app/Services/Qr/QrPayloadValidator.php +++ b/app/Services/Qr/QrPayloadValidator.php @@ -260,6 +260,12 @@ class QrPayloadValidator $categories = []; } + $format = in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true) + ? ($input['format'] ?? 'in_person') + : 'in_person'; + + $virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format); + // Contributions always take payment; ticketing only for paid tiers; free never. $acceptsPayment = match ($mode) { 'contributions' => true, @@ -289,11 +295,63 @@ class QrPayloadValidator 'accepts_payment' => $acceptsPayment, 'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL), 'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null, + 'format' => $format, + 'virtual_sessions' => $virtualSessions, ], 'destination_url' => null, ]; } + /** @param mixed $sessions */ + private function normalizeVirtualSessions(mixed $sessions, string $format): array + { + if ($format === 'in_person') { + return []; + } + + if (! is_array($sessions)) { + return []; + } + + $normalized = []; + foreach ($sessions as $session) { + if (! is_array($session)) { + continue; + } + + $title = trim((string) ($session['title'] ?? '')); + if ($title === '') { + continue; + } + + $id = trim((string) ($session['id'] ?? '')); + $roomType = in_array($session['meet_room_type'] ?? '', ['town_hall', 'webinar'], true) + ? (string) $session['meet_room_type'] + : 'town_hall'; + + $itemRefs = []; + foreach ((array) ($session['programme_item_refs'] ?? []) as $ref) { + $ref = trim((string) $ref); + if ($ref !== '') { + $itemRefs[] = mb_substr($ref, 0, 80); + } + } + + $normalized[] = [ + 'id' => $id !== '' ? mb_substr($id, 0, 64) : null, + 'title' => mb_substr($title, 0, 120), + 'meet_room_type' => $roomType, + 'scheduled_at' => QrDateFormatter::normalize((string) ($session['scheduled_at'] ?? '')), + 'duration_minutes' => max(15, min(480, (int) ($session['duration_minutes'] ?? 60))), + 'meet_room_uuid' => ($uuid = trim((string) ($session['meet_room_uuid'] ?? ''))) !== '' ? $uuid : null, + 'join_url' => ($url = trim((string) ($session['join_url'] ?? ''))) !== '' ? $url : null, + 'programme_item_refs' => array_values($itemRefs), + ]; + } + + return array_values($normalized); + } + /** @param array $tiers */ private function eventHasPaidTier(array $tiers): bool { diff --git a/config/meet.php b/config/meet.php new file mode 100644 index 0000000..2fc09c1 --- /dev/null +++ b/config/meet.php @@ -0,0 +1,8 @@ + rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'), + 'key' => env('MEET_API_KEY_EVENTS'), + +]; diff --git a/config/sms.php b/config/sms.php new file mode 100644 index 0000000..9e72577 --- /dev/null +++ b/config/sms.php @@ -0,0 +1,7 @@ + env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'), + 'platform_api_key' => env('SMS_API_KEY_EVENTS'), + 'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'), +]; diff --git a/config/smtp.php b/config/smtp.php new file mode 100644 index 0000000..18fbd3f --- /dev/null +++ b/config/smtp.php @@ -0,0 +1,8 @@ + env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'), + 'platform_api_key' => env('SMTP_API_KEY_EVENTS'), + 'from' => env('EVENTS_SMTP_FROM', 'events@ladill.com'), + 'from_name' => env('EVENTS_SMTP_FROM_NAME', 'Ladill Events'), +]; diff --git a/resources/views/public/qr/landing.blade.php b/resources/views/public/qr/landing.blade.php index 493ae11..75df245 100644 --- a/resources/views/public/qr/landing.blade.php +++ b/resources/views/public/qr/landing.blade.php @@ -711,6 +711,26 @@

{{ $content['description'] }}

@endif + @php + $evFormat = $content['format'] ?? 'in_person'; + $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()) + + @endif + @if(! $evOpen)
{{ $evMode === 'contributions' ? 'Contributions are currently closed.' : 'Registration is currently closed.' }}
@else diff --git a/resources/views/qr-codes/partials/type-fields-create.blade.php b/resources/views/qr-codes/partials/type-fields-create.blade.php index 5515642..5d79583 100644 --- a/resources/views/qr-codes/partials/type-fields-create.blade.php +++ b/resources/views/qr-codes/partials/type-fields-create.blade.php @@ -417,6 +417,51 @@ + + {{-- Event format + virtual sessions --}} +
+ +

Event format

+
+ @foreach (['in_person' => 'In person', 'virtual' => 'Virtual', 'hybrid' => 'Hybrid'] as $value => $label) + + @endforeach +
+ +
+
+

Virtual sessions

+ +
+ +
+
+
diff --git a/resources/views/qr-codes/partials/type-fields-edit.blade.php b/resources/views/qr-codes/partials/type-fields-edit.blade.php index 716b8c7..2ce4371 100644 --- a/resources/views/qr-codes/partials/type-fields-edit.blade.php +++ b/resources/views/qr-codes/partials/type-fields-edit.blade.php @@ -426,6 +426,52 @@
+ +
+ +

Event format

+
+ @foreach (['in_person' => 'In person', 'virtual' => 'Virtual', 'hybrid' => 'Hybrid'] as $value => $label) + + @endforeach +
+
+
+

Virtual sessions

+ +
+ +
+
+