Add virtual event sessions with Meet sync and platform-billed messaging.
Deploy Ladill Events / deploy (push) Successful in 41s
Deploy Ladill Events / deploy (push) Successful in 41s
Events can define hybrid/virtual sessions synced to Meet rooms; SMS and email use wallet-billed platform APIs instead of Termii and Laravel mail. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -39,6 +39,18 @@ PAY_API_KEY_EVENTS=
|
|||||||
IDENTITY_API_URL=https://ladill.com/api
|
IDENTITY_API_URL=https://ladill.com/api
|
||||||
IDENTITY_API_KEY_EVENTS=
|
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_ENABLED=true
|
||||||
AFIA_PRODUCT=events
|
AFIA_PRODUCT=events
|
||||||
AFIA_PROVIDER=openai
|
AFIA_PROVIDER=openai
|
||||||
|
|||||||
@@ -5,18 +5,20 @@ namespace App\Http\Controllers\Events;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\QrCode;
|
use App\Models\QrCode;
|
||||||
use App\Models\QrEventRegistration;
|
use App\Models\QrEventRegistration;
|
||||||
use App\Notifications\EventProgrammeSharedNotification;
|
|
||||||
use App\Services\Billing\SmsService;
|
use App\Services\Billing\SmsService;
|
||||||
|
use App\Services\Events\EventEmailService;
|
||||||
use App\Support\Events\EventBadgeZpl;
|
use App\Support\Events\EventBadgeZpl;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\Notification;
|
|
||||||
use Illuminate\View\View;
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class AttendeeController extends Controller
|
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
|
public function hub(Request $request): View
|
||||||
{
|
{
|
||||||
@@ -163,13 +165,19 @@ class AttendeeController extends Controller
|
|||||||
|
|
||||||
foreach ($recipients as $reg) {
|
foreach ($recipients as $reg) {
|
||||||
if ($reg->attendee_email) {
|
if ($reg->attendee_email) {
|
||||||
Notification::route('mail', $reg->attendee_email)
|
$this->email->sendProgrammeShare(
|
||||||
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
|
(string) $event->user->public_id,
|
||||||
|
$reg->attendee_email,
|
||||||
|
$eventName,
|
||||||
|
$programmeUrl,
|
||||||
|
$reg->attendee_name,
|
||||||
|
);
|
||||||
$emailed++;
|
$emailed++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reg->attendee_phone) {
|
if ($reg->attendee_phone) {
|
||||||
$this->sms->send(
|
$this->sms->send(
|
||||||
|
(string) $event->user->public_id,
|
||||||
$reg->attendee_phone,
|
$reg->attendee_phone,
|
||||||
sprintf(
|
sprintf(
|
||||||
'Hi %s, the programme for %s is here: %s',
|
'Hi %s, the programme for %s is here: %s',
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class QrSetting extends Model
|
|||||||
'organizer',
|
'organizer',
|
||||||
'registration_open',
|
'registration_open',
|
||||||
'badge_fields',
|
'badge_fields',
|
||||||
|
'format',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,9 @@ class QrSetting extends Model
|
|||||||
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
|
'organizer' => mb_substr(trim((string) ($input['organizer'] ?? '')), 0, 120),
|
||||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||||
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
|
'badge_fields' => $badgeFields !== [] ? $badgeFields : ['Company', 'Role'],
|
||||||
|
'format' => in_array($input['format'] ?? 'in_person', ['in_person', 'virtual', 'hybrid'], true)
|
||||||
|
? ($input['format'] ?? 'in_person')
|
||||||
|
: 'in_person',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PlatformEmailClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) config('smtp.platform_api_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool
|
||||||
|
{
|
||||||
|
if ($this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class PlatformSmsClient
|
||||||
|
{
|
||||||
|
private function base(): string
|
||||||
|
{
|
||||||
|
return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function token(): string
|
||||||
|
{
|
||||||
|
return (string) config('sms.platform_api_key', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,20 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Services\Billing;
|
namespace App\Services\Billing;
|
||||||
|
|
||||||
use Illuminate\Support\Facades\Http;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class SmsService
|
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);
|
$phone = preg_replace('/\D+/', '', $to);
|
||||||
if (strlen($phone) < 9) {
|
if (strlen($phone) < 9) {
|
||||||
return;
|
return;
|
||||||
@@ -27,17 +19,6 @@ class SmsService
|
|||||||
$phone = '233'.substr($phone, 1);
|
$phone = '233'.substr($phone, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
$this->platform->send($ownerPublicId, $phone, $message);
|
||||||
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()]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Events;
|
||||||
|
|
||||||
|
use App\Services\Billing\PlatformEmailClient;
|
||||||
|
use Illuminate\Support\Facades\View;
|
||||||
|
|
||||||
|
class EventEmailService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly PlatformEmailClient $platform) {}
|
||||||
|
|
||||||
|
public function sendProgrammeShare(
|
||||||
|
string $ownerPublicId,
|
||||||
|
string $to,
|
||||||
|
string $eventName,
|
||||||
|
string $programmeUrl,
|
||||||
|
?string $attendeeName = null,
|
||||||
|
): bool {
|
||||||
|
$html = View::make('mail.notifications.event-programme', [
|
||||||
|
'eventName' => $eventName,
|
||||||
|
'programmeUrl' => $programmeUrl,
|
||||||
|
'attendeeName' => $attendeeName,
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
return $this->platform->send(
|
||||||
|
$ownerPublicId,
|
||||||
|
$to,
|
||||||
|
'Programme for '.$eventName,
|
||||||
|
$html,
|
||||||
|
strip_tags(str_replace(['<br>', '<br/>', '<br />'], "\n", $html)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -252,6 +252,11 @@ class EventRegistrationService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$ownerPublicId = (string) $registration->qrCode?->user?->public_id;
|
||||||
|
if ($ownerPublicId === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$content = $registration->qrCode?->content() ?? [];
|
$content = $registration->qrCode?->content() ?? [];
|
||||||
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
|
$eventName = $content['name'] ?? $registration->qrCode?->label ?? config('app.name');
|
||||||
$firstName = explode(' ', $registration->attendee_name)[0];
|
$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<string, mixed> $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
|
private function uniqueBadgeCode(): string
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Meet;
|
||||||
|
|
||||||
|
use App\Models\QrCode;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class EventMeetSyncService
|
||||||
|
{
|
||||||
|
public function sync(QrCode $event, User $owner): QrCode
|
||||||
|
{
|
||||||
|
if ($event->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<string> $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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services\Meet;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client for the Ladill Meet service API — virtual sessions linked to Events.
|
||||||
|
*/
|
||||||
|
class MeetClient
|
||||||
|
{
|
||||||
|
public function __construct(private readonly string $ownerRef) {}
|
||||||
|
|
||||||
|
public static function for(string $ownerRef): self
|
||||||
|
{
|
||||||
|
return new self($ownerRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|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<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
private function post(string $path, array $data): array
|
||||||
|
{
|
||||||
|
return $this->request('post', $path, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function patch(string $path, array $data): array
|
||||||
|
{
|
||||||
|
return $this->request('patch', $path, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ class QrCodeManagerService
|
|||||||
private QrWalletBillingService $billing,
|
private QrWalletBillingService $billing,
|
||||||
private QrImageGeneratorService $imageGenerator,
|
private QrImageGeneratorService $imageGenerator,
|
||||||
private QrPayloadValidator $payloadValidator,
|
private QrPayloadValidator $payloadValidator,
|
||||||
|
private \App\Services\Meet\EventMeetSyncService $meetSync,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function walletFor(User $user): QrWallet
|
public function walletFor(User $user): QrWallet
|
||||||
@@ -158,6 +159,10 @@ class QrCodeManagerService
|
|||||||
$this->billing->debitForQrCreation($wallet, $qrCode);
|
$this->billing->debitForQrCreation($wallet, $qrCode);
|
||||||
$this->imageGenerator->generateAndStore($qrCode);
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
|
|
||||||
|
if ($type === QrCode::TYPE_EVENT) {
|
||||||
|
$qrCode = $this->meetSync->sync($qrCode->fresh(), $user);
|
||||||
|
}
|
||||||
|
|
||||||
return $qrCode->fresh(['document']);
|
return $qrCode->fresh(['document']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,6 +299,10 @@ class QrCodeManagerService
|
|||||||
$this->imageGenerator->generateAndStore($qrCode);
|
$this->imageGenerator->generateAndStore($qrCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($qrCode->type === QrCode::TYPE_EVENT) {
|
||||||
|
$qrCode = $this->meetSync->sync($qrCode->fresh(), $qrCode->user);
|
||||||
|
}
|
||||||
|
|
||||||
return $qrCode->fresh(['document']);
|
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_VCARD => ['first_name', 'last_name', 'phone', 'email', 'company', 'website', 'address', 'note', 'social'],
|
||||||
QrCode::TYPE_BUSINESS => ['name', 'tagline', 'phone', 'email', 'website', 'address', 'hours'],
|
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_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_ITINERARY => ['title', 'subtitle', 'description', 'event_date', 'location', 'brand_color', 'days'],
|
||||||
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
QrCode::TYPE_DOCUMENT => ['allow_download'],
|
||||||
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
QrCode::TYPE_MENU => ['menu_title', 'sections', 'accepts_payment', 'brand_color', 'shipping_type', 'shipping_fee', 'free_shipping_above'],
|
||||||
|
|||||||
@@ -260,6 +260,12 @@ class QrPayloadValidator
|
|||||||
$categories = [];
|
$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.
|
// Contributions always take payment; ticketing only for paid tiers; free never.
|
||||||
$acceptsPayment = match ($mode) {
|
$acceptsPayment = match ($mode) {
|
||||||
'contributions' => true,
|
'contributions' => true,
|
||||||
@@ -289,11 +295,63 @@ class QrPayloadValidator
|
|||||||
'accepts_payment' => $acceptsPayment,
|
'accepts_payment' => $acceptsPayment,
|
||||||
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
'registration_open' => filter_var($input['registration_open'] ?? true, FILTER_VALIDATE_BOOL),
|
||||||
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
|
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
|
||||||
|
'format' => $format,
|
||||||
|
'virtual_sessions' => $virtualSessions,
|
||||||
],
|
],
|
||||||
'destination_url' => null,
|
'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<int, array{price: float}> $tiers */
|
/** @param array<int, array{price: float}> $tiers */
|
||||||
private function eventHasPaidTier(array $tiers): bool
|
private function eventHasPaidTier(array $tiers): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
'url' => rtrim((string) env('MEET_API_URL', 'https://meet.ladill.com/api/service/v1'), '/'),
|
||||||
|
'key' => env('MEET_API_KEY_EVENTS'),
|
||||||
|
|
||||||
|
];
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'platform_api_url' => 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'),
|
||||||
|
];
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'platform_api_url' => 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'),
|
||||||
|
];
|
||||||
@@ -711,6 +711,26 @@
|
|||||||
<p class="mt-6 text-center text-sm leading-relaxed text-slate-600">{{ $content['description'] }}</p>
|
<p class="mt-6 text-center text-sm leading-relaxed text-slate-600">{{ $content['description'] }}</p>
|
||||||
@endif
|
@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())
|
||||||
|
<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)
|
||||||
|
<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
|
||||||
|
<p class="mt-2 text-xs font-semibold text-indigo-600">Join virtual session →</p>
|
||||||
|
</a>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
@if(! $evOpen)
|
@if(! $evOpen)
|
||||||
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-center text-sm font-medium text-amber-700">{{ $evMode === 'contributions' ? 'Contributions are currently closed.' : 'Registration is currently closed.' }}</div>
|
<div class="mt-6 rounded-2xl border border-amber-200 bg-amber-50 px-5 py-4 text-center text-sm font-medium text-amber-700">{{ $evMode === 'contributions' ? 'Contributions are currently closed.' : 'Registration is currently closed.' }}</div>
|
||||||
@else
|
@else
|
||||||
|
|||||||
@@ -417,6 +417,51 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="text" name="location" value="{{ old('location') }}" placeholder="Venue / location"
|
<input type="text" name="location" value="{{ old('location') }}" placeholder="Venue / location"
|
||||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
|
||||||
|
{{-- Event format + virtual sessions --}}
|
||||||
|
<div x-data="{
|
||||||
|
format: @js(old('format', $ed['format'] ?? 'in_person')),
|
||||||
|
sessions: {{ Illuminate\Support\Js::from(old('virtual_sessions', $ed['virtual_sessions'] ?? [])) }},
|
||||||
|
addSession() { this.sessions.push({ title: '', meet_room_type: 'town_hall', scheduled_at: '', duration_minutes: 60 }); },
|
||||||
|
removeSession(i) { this.sessions.splice(i, 1); }
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="format" :value="format">
|
||||||
|
<p class="mb-2 text-xs font-semibold text-slate-600">Event format</p>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
@foreach (['in_person' => 'In person', 'virtual' => 'Virtual', 'hybrid' => 'Hybrid'] as $value => $label)
|
||||||
|
<button type="button" @click="format = '{{ $value }}'"
|
||||||
|
:class="format === '{{ $value }}' ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 bg-white text-slate-600'"
|
||||||
|
class="rounded-xl border px-3 py-2 text-xs font-medium">{{ $label }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div x-show="format === 'virtual' || format === 'hybrid'" x-cloak class="mt-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Virtual sessions</p>
|
||||||
|
<button type="button" @click="addSession()" class="text-xs font-semibold text-indigo-600">+ Add session</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(session, index) in sessions" :key="index">
|
||||||
|
<div class="rounded-xl border border-slate-200 p-3 space-y-2">
|
||||||
|
<input type="hidden" :name="'virtual_sessions[' + index + '][id]'" :value="session.id || ''">
|
||||||
|
<input type="text" :name="'virtual_sessions[' + index + '][title]'" x-model="session.title" placeholder="Session title"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3">
|
||||||
|
<select :name="'virtual_sessions[' + index + '][meet_room_type]'" x-model="session.meet_room_type"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<option value="town_hall">Conference</option>
|
||||||
|
<option value="webinar">Webinar</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" :name="'virtual_sessions[' + index + '][scheduled_at]'" x-model="session.scheduled_at"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<input type="number" min="15" max="480" :name="'virtual_sessions[' + index + '][duration_minutes]'" x-model="session.duration_minutes"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm" placeholder="Minutes">
|
||||||
|
</div>
|
||||||
|
<button type="button" @click="removeSession(index)" class="text-xs text-red-600">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<input type="text" name="organizer" value="{{ old('organizer', $ed['organizer'] ?? '') }}" placeholder="Organizer name"
|
<input type="text" name="organizer" value="{{ old('organizer', $ed['organizer'] ?? '') }}" placeholder="Organizer name"
|
||||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
|||||||
@@ -426,6 +426,52 @@
|
|||||||
</div>
|
</div>
|
||||||
<input type="text" name="location" value="{{ $c['location'] ?? '' }}" placeholder="Venue / location"
|
<input type="text" name="location" value="{{ $c['location'] ?? '' }}" placeholder="Venue / location"
|
||||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
|
||||||
|
<div x-data="{
|
||||||
|
format: @js(old('format', $c['format'] ?? 'in_person')),
|
||||||
|
sessions: {{ Illuminate\Support\Js::from(old('virtual_sessions', $c['virtual_sessions'] ?? [])) }},
|
||||||
|
addSession() { this.sessions.push({ title: '', meet_room_type: 'town_hall', scheduled_at: '', duration_minutes: 60 }); },
|
||||||
|
removeSession(i) { this.sessions.splice(i, 1); }
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="format" :value="format">
|
||||||
|
<p class="mb-2 text-xs font-semibold text-slate-600">Event format</p>
|
||||||
|
<div class="grid grid-cols-3 gap-2">
|
||||||
|
@foreach (['in_person' => 'In person', 'virtual' => 'Virtual', 'hybrid' => 'Hybrid'] as $value => $label)
|
||||||
|
<button type="button" @click="format = '{{ $value }}'"
|
||||||
|
:class="format === '{{ $value }}' ? 'border-indigo-500 bg-indigo-50 text-indigo-700' : 'border-slate-200 bg-white text-slate-600'"
|
||||||
|
class="rounded-xl border px-3 py-2 text-xs font-medium">{{ $label }}</button>
|
||||||
|
@endforeach
|
||||||
|
</div>
|
||||||
|
<div x-show="format === 'virtual' || format === 'hybrid'" x-cloak class="mt-4 space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-xs font-semibold text-slate-600">Virtual sessions</p>
|
||||||
|
<button type="button" @click="addSession()" class="text-xs font-semibold text-indigo-600">+ Add session</button>
|
||||||
|
</div>
|
||||||
|
<template x-for="(session, index) in sessions" :key="index">
|
||||||
|
<div class="rounded-xl border border-slate-200 p-3 space-y-2">
|
||||||
|
<input type="hidden" :name="'virtual_sessions[' + index + '][id]'" :value="session.id || ''">
|
||||||
|
<input type="hidden" :name="'virtual_sessions[' + index + '][meet_room_uuid]'" :value="session.meet_room_uuid || ''">
|
||||||
|
<input type="hidden" :name="'virtual_sessions[' + index + '][join_url]'" :value="session.join_url || ''">
|
||||||
|
<input type="text" :name="'virtual_sessions[' + index + '][title]'" x-model="session.title" placeholder="Session title"
|
||||||
|
class="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3">
|
||||||
|
<select :name="'virtual_sessions[' + index + '][meet_room_type]'" x-model="session.meet_room_type"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<option value="town_hall">Conference</option>
|
||||||
|
<option value="webinar">Webinar</option>
|
||||||
|
</select>
|
||||||
|
<input type="date" :name="'virtual_sessions[' + index + '][scheduled_at]'" x-model="session.scheduled_at"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
<input type="number" min="15" max="480" :name="'virtual_sessions[' + index + '][duration_minutes]'" x-model="session.duration_minutes"
|
||||||
|
class="rounded-lg border border-slate-200 px-3 py-2 text-sm">
|
||||||
|
</div>
|
||||||
|
<p x-show="session.join_url" class="text-xs text-emerald-700">Join link ready</p>
|
||||||
|
<button type="button" @click="removeSession(index)" class="text-xs text-red-600">Remove</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2">
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
<input type="text" name="organizer" value="{{ $c['organizer'] ?? '' }}" placeholder="Organizer name"
|
<input type="text" name="organizer" value="{{ $c['organizer'] ?? '' }}" placeholder="Organizer name"
|
||||||
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
class="rounded-xl border border-slate-200 px-3.5 py-2.5 text-sm placeholder:text-slate-400 focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
|
||||||
|
|||||||
Reference in New Issue
Block a user