Fix post-registration 404, add speaker management, and simplify event creation.
Deploy Ladill Events / deploy (push) Successful in 28s

Proxy registration transaction paths on Events, add a join button on the
confirmation page, introduce speaker roster management, and replace the QR
design wizard with merchant-style create/show flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-03 13:44:53 +00:00
co-authored by Cursor
parent d942ebaa83
commit d2ec31f7a5
16 changed files with 811 additions and 3 deletions
@@ -0,0 +1,72 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Events\EventSpeakerService;
use App\Services\Qr\QrCodeManagerService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SpeakerController extends Controller
{
public function __construct(
private readonly EventSpeakerService $speakers,
private readonly QrCodeManagerService $manager,
) {}
public function hub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $this->speakers->eventsForAccount($account->id, $search !== '' ? $search : null)
->map(function (QrCode $event) {
$event->speaker_count = count($this->speakers->rosterFor($event));
return $event;
});
return view('events.speakers-hub', [
'events' => $events,
'search' => $search,
]);
}
public function index(QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
return view('events.speakers', [
'qrCode' => $event,
'roster' => (array) ($event->content()['speakers'] ?? []),
'programmeAssignments' => $this->speakers->programmeAssignments($event),
'programme' => $this->speakers->linkedProgramme($event),
]);
}
public function update(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$validated = $request->validate([
'speakers' => ['nullable', 'array'],
'speakers.*.name' => ['nullable', 'string', 'max:120'],
'speakers.*.email' => ['nullable', 'email', 'max:190'],
'speakers.*.role' => ['nullable', 'string', 'max:80'],
'speakers.*.bio' => ['nullable', 'string', 'max:500'],
]);
$this->manager->update($event, [
'speakers' => $validated['speakers'] ?? [],
]);
return redirect()
->route('speakers.show', $event)
->with('success', 'Speaker roster saved.');
}
}
@@ -82,6 +82,13 @@ class QrCodeController extends Controller
$requestedType = QrCode::TYPE_EVENT;
}
if ($requestedType === QrCode::TYPE_EVENT) {
return view('events.create', [
'pricePerQr' => QrWallet::pricePerQr(),
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
]);
}
return view('qr-codes.create', [
'wallet' => $wallet,
'requestedType' => $requestedType,
@@ -213,6 +220,16 @@ class QrCodeController extends Controller
->get(['id', 'label'])
: collect();
if ($qrCode->type === QrCode::TYPE_EVENT) {
return view('events.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'itineraryOptions' => $itineraryOptions,
'customDomains' => \App\Models\CustomDomain::where('qr_code_id', $qrCode->id)->get(),
'customDomainsEnabled' => app(\App\Services\CustomDomain\CustomDomainService::class)->enabled(),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
@@ -36,7 +36,7 @@ class RedirectLegacyQrToLadillLink
return $next($request);
}
if ($this->servesStoredAsset($request)) {
if ($this->servesStoredAsset($request) || $this->servesRegistrationTransaction($request)) {
return $next($request);
}
@@ -64,4 +64,24 @@ class RedirectLegacyQrToLadillLink
return (bool) preg_match('#^(image/\d+|item-image/\d+/\d+)$#', $suffix);
}
private function servesRegistrationTransaction(Request $request): bool
{
$shortCode = $request->route('shortCode');
if (! is_string($shortCode) || $shortCode === '') {
return false;
}
$path = ltrim($request->path(), '/');
$prefix = 'q/'.$shortCode.'/';
if (! str_starts_with($path, $prefix)) {
return false;
}
$suffix = substr($path, strlen($prefix));
return $suffix === 'register'
|| str_starts_with($suffix, 'register/')
|| str_starts_with($suffix, 'registered/');
}
}
+127
View File
@@ -0,0 +1,127 @@
<?php
namespace App\Services\Events;
use App\Models\QrCode;
use Illuminate\Support\Collection;
class EventSpeakerService
{
public function __construct(private readonly EventProgrammeService $programmes) {}
public function linkedProgramme(QrCode $event): ?QrCode
{
if ($event->type !== QrCode::TYPE_EVENT) {
return null;
}
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::query()
->where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
/** @return list<array{name: string, email: string, role: string, bio: string, source: string}> */
public function rosterFor(QrCode $event): array
{
if ($event->type !== QrCode::TYPE_EVENT) {
return [];
}
$content = $event->content();
$roster = [];
foreach ((array) ($content['speakers'] ?? []) as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$roster[] = [
'name' => $name,
'email' => trim((string) ($speaker['email'] ?? '')),
'role' => trim((string) ($speaker['role'] ?? '')),
'bio' => trim((string) ($speaker['bio'] ?? '')),
'source' => 'roster',
];
}
foreach ($this->programmeAssignments($event) as $assignment) {
$name = trim((string) ($assignment['name'] ?? ''));
if ($name === '') {
continue;
}
$exists = collect($roster)->contains(fn ($row) => strcasecmp($row['name'], $name) === 0);
if ($exists) {
continue;
}
$roster[] = [
'name' => $name,
'email' => '',
'role' => trim((string) ($assignment['session'] ?? '')),
'bio' => '',
'source' => 'programme',
];
}
return $roster;
}
/** @return list<array{name: string, session: string, day: string, time: string}> */
public function programmeAssignments(QrCode $event): array
{
$programme = $this->linkedProgramme($event);
$snapshot = $this->programmes->snapshot($programme);
if (! $snapshot) {
return [];
}
$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;
}
$host = trim((string) ($item['host'] ?? ''));
if ($host === '') {
continue;
}
$assignments[] = [
'name' => $host,
'session' => trim((string) ($item['title'] ?? '')),
'day' => $dayLabel,
'time' => trim((string) ($item['time'] ?? '')),
];
}
}
return $assignments;
}
/** @return Collection<int, QrCode> */
public function eventsForAccount(int $userId, ?string $search = null): Collection
{
return QrCode::query()
->where('user_id', $userId)
->where('type', QrCode::TYPE_EVENT)
->when($search !== null && $search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get();
}
}
+6 -2
View File
@@ -43,7 +43,11 @@ class QrCodeManagerService
$type = (string) ($data['type'] ?? '');
$validated = $this->payloadValidator->validateForCreate($type, $data);
$style = QrStyleDefaults::merge($data['style'] ?? null);
$styleInput = $data['style'] ?? null;
if ($type === QrCode::TYPE_EVENT && empty(array_filter((array) $styleInput))) {
$styleInput = $user->getOrCreateQrSetting()->resolvedDefaultStyle();
}
$style = QrStyleDefaults::merge($styleInput);
return DB::transaction(function () use ($user, $wallet, $data, $type, $validated, $style) {
$documentId = null;
@@ -360,7 +364,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', 'format', 'virtual_sessions'],
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', 'speakers', 'programme_qr_id'],
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'],
+30
View File
@@ -265,6 +265,7 @@ class QrPayloadValidator
: 'in_person';
$virtualSessions = $this->normalizeVirtualSessions($input['virtual_sessions'] ?? [], $format);
$speakers = $this->normalizeSpeakers($input['speakers'] ?? null, $input);
// Contributions always take payment; ticketing only for paid tiers; free never.
$acceptsPayment = match ($mode) {
@@ -297,6 +298,7 @@ class QrPayloadValidator
'programme_qr_id' => ($pid = (int) ($input['programme_qr_id'] ?? 0)) > 0 ? $pid : null,
'format' => $format,
'virtual_sessions' => $virtualSessions,
'speakers' => $speakers,
],
'destination_url' => null,
];
@@ -352,6 +354,34 @@ class QrPayloadValidator
return array_values($normalized);
}
/** @return list<array{name: string, email: string, role: string, bio: string}> */
private function normalizeSpeakers(mixed $speakers, array $input): array
{
$rows = is_array($speakers) ? $speakers : (array) ($input['speakers'] ?? []);
$normalized = [];
foreach ($rows as $speaker) {
if (! is_array($speaker)) {
continue;
}
$name = trim((string) ($speaker['name'] ?? ''));
if ($name === '') {
continue;
}
$email = trim((string) ($speaker['email'] ?? ''));
$normalized[] = [
'name' => mb_substr($name, 0, 120),
'email' => filter_var($email, FILTER_VALIDATE_EMAIL) ? $email : '',
'role' => mb_substr(trim((string) ($speaker['role'] ?? '')), 0, 80),
'bio' => mb_substr(trim((string) ($speaker['bio'] ?? '')), 0, 500),
];
}
return array_values($normalized);
}
/** @param array<int, array{price: float}> $tiers */
private function eventHasPaidTier(array $tiers): bool
{
+75
View File
@@ -0,0 +1,75 @@
<x-user-layout>
<x-slot name="title">Create event</x-slot>
<div class="mx-auto max-w-2xl space-y-6" x-data="{ type: 'event' }">
<div>
<a href="{{ route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
Events
</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">Create event</h1>
<p class="mt-1 text-sm text-slate-500">Set up registration, ticketing, and your event page. A Ladill Link QR code is generated automatically customize it later in QR Plus if needed.</p>
</div>
@if(session('error'))
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">{{ session('error') }}</div>
@endif
@if($errors->any())
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
<ul class="list-inside list-disc space-y-0.5">
@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach
</ul>
</div>
@endif
<form method="post" action="{{ route('events.store') }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf
<input type="hidden" name="type" value="event">
<div>
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
<input type="text" name="label" value="{{ old('label') }}" required maxlength="120"
placeholder="e.g. Tech summit 2026"
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
<p class="mt-1 text-xs text-slate-400">Private name to find this event in your dashboard.</p>
</div>
<div x-data="{
slug: @js(old('custom_short_code', '')),
status: '',
timer: null,
checkUrl: @js(route('events.check-slug')),
check() {
clearTimeout(this.timer);
const s = this.slug.trim();
if (s === '') { this.status = ''; return; }
if (!/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/.test(s)) { this.status = 'invalid'; return; }
this.status = 'checking';
this.timer = setTimeout(() => {
fetch(this.checkUrl + '?code=' + encodeURIComponent(s))
.then(r => r.json())
.then(d => { this.status = d.available ? 'available' : 'taken'; })
.catch(() => { this.status = ''; });
}, 400);
},
}">
<label class="block text-sm font-medium text-slate-700">Custom link <span class="font-normal text-slate-400">(optional)</span></label>
<div class="mt-1 flex items-stretch rounded-xl border focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500"
:class="status === 'available' ? 'border-green-400' : status === 'taken' || status === 'invalid' ? 'border-red-400' : 'border-slate-200'">
<span class="flex items-center rounded-l-xl border-r border-slate-200 bg-slate-50 px-3 text-xs text-slate-400 whitespace-nowrap select-none">{{ \App\Support\LadillLink::baseUrl() }}/</span>
<input type="text" name="custom_short_code" x-model="slug" @input="check()" maxlength="20" autocomplete="off"
class="min-w-0 flex-1 rounded-r-xl bg-white px-3 py-2.5 text-sm focus:outline-none" placeholder="my-event">
</div>
<p class="mt-1 text-xs text-slate-400">Leave blank to auto-generate your Ladill Link.</p>
</div>
@include('qr-codes.partials.type-fields-create', ['accountEventDefaults' => $accountEventDefaults ?? []])
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-500">
GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance when you create this event.
</p>
<button type="submit" class="btn-primary w-full">Create event</button>
</form>
</div>
</x-user-layout>
+114
View File
@@ -0,0 +1,114 @@
<x-user-layout>
<x-slot name="title">{{ $qrCode->label }}</x-slot>
@php
$content = $qrCode->content();
$publicUrl = \App\Support\LadillLink::url($qrCode->short_code);
@endphp
<div class="mx-auto max-w-5xl space-y-6" x-data="{ type: @js($qrCode->type) }">
<div class="flex flex-wrap items-start justify-between gap-4">
<div>
<a href="{{ route('events.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
Events
</a>
<h1 class="mt-2 text-xl font-semibold text-slate-900">{{ $content['name'] ?? $qrCode->label }}</h1>
<p class="mt-1 inline-flex flex-wrap items-center gap-2 text-sm text-slate-500">
<span class="rounded-full bg-indigo-50 px-2 py-0.5 text-xs font-semibold text-indigo-700">Event</span>
@if($qrCode->is_active)
<span class="text-emerald-600"> Live</span>
@else
<span class="text-slate-400"> Inactive</span>
@endif
</p>
</div>
<a href="{{ $publicUrl }}" target="_blank" rel="noopener"
class="inline-flex items-center gap-1.5 rounded-xl border border-slate-200 bg-white px-3.5 py-2 text-sm font-semibold text-slate-700 shadow-sm hover:bg-slate-50">
View registration page
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>
</a>
</div>
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
{{ session($flash) }}
</div>
@endif
@endforeach
@if($errors->any())
<div class="rounded-xl border border-red-100 bg-red-50 px-4 py-3 text-sm text-red-700">
<ul class="list-inside list-disc space-y-0.5">@foreach($errors->all() as $error)<li>{{ $error }}</li>@endforeach</ul>
</div>
@endif
<div class="grid gap-3 sm:grid-cols-3">
<a href="{{ route('events.attendees', $qrCode) }}" class="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Attendees</a>
<a href="{{ route('speakers.show', $qrCode) }}" class="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Speakers</a>
<a href="{{ route('events.badge-printing', $qrCode) }}" class="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-700 hover:bg-slate-50">Badge printing</a>
</div>
<div class="grid gap-6 lg:grid-cols-[320px_1fr]">
<div class="space-y-4 lg:sticky lg:top-6 lg:self-start">
<div class="rounded-2xl border border-slate-200 bg-white p-5 text-center">
@if($previewDataUri)
<img src="{{ $previewDataUri }}" alt="QR code" class="mx-auto h-44 w-44">
@endif
<p class="mt-3 break-all text-xs text-slate-400">{{ $publicUrl }}</p>
<div class="mt-4 flex flex-wrap justify-center gap-2">
@foreach(['png' => 'PNG', 'svg' => 'SVG', 'pdf' => 'PDF'] as $fmt => $flabel)
<a href="{{ route('events.download', [$qrCode, $fmt]) }}"
class="rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-semibold text-slate-600 hover:bg-slate-50">{{ $flabel }}</a>
@endforeach
</div>
<p class="mt-3 text-[11px] leading-5 text-slate-400">System-generated QR code. Customize colors, logos, and frames in QR Plus.</p>
</div>
@if(!empty($customDomainsEnabled))
<div class="rounded-2xl border border-slate-200 bg-white p-5">
<h3 class="text-sm font-semibold text-slate-900">Custom domain</h3>
<p class="mt-1 text-xs text-slate-500">Optional your Ladill Link always works.</p>
@forelse($customDomains as $cd)
<div class="mt-3 rounded-xl border border-slate-200 p-3 text-xs text-slate-600">
<span class="font-medium text-slate-900">{{ $cd->host }}</span>
</div>
@empty
<form method="post" action="{{ route('events.custom-domain.store', $qrCode) }}" class="mt-3 flex items-end gap-2">
@csrf
<input type="text" name="host" placeholder="myevent.com" required class="mt-1 w-full rounded-xl border-slate-200 text-sm">
<button class="rounded-xl bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Add</button>
</form>
@endforelse
</div>
@endif
</div>
<form method="post" action="{{ route('events.update', $qrCode) }}" enctype="multipart/form-data" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6">
@csrf @method('PATCH')
<div>
<label class="block text-sm font-medium text-slate-700">Dashboard label</label>
<input type="text" name="label" value="{{ old('label', $qrCode->label) }}" required maxlength="120"
class="mt-1 w-full rounded-xl border-slate-200 text-sm focus:border-indigo-500 focus:ring-indigo-500">
</div>
<label class="flex cursor-pointer items-center justify-between rounded-xl border border-slate-200 bg-white px-4 py-3 shadow-sm">
<div>
<p class="text-sm font-semibold text-slate-900">Registration page live</p>
<p class="mt-0.5 text-xs text-slate-500">Turn off to pause registrations without deleting the event.</p>
</div>
<div class="relative ml-4 shrink-0">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" class="sr-only peer" @checked(old('is_active', $qrCode->is_active))>
<div class="h-6 w-11 rounded-full bg-slate-200 transition-colors duration-200 peer-checked:bg-indigo-600"></div>
<div class="absolute left-0.5 top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform duration-200 peer-checked:translate-x-5"></div>
</div>
</label>
@include('qr-codes.partials.type-fields-edit', ['itineraryOptions' => $itineraryOptions])
<button type="submit" class="btn-primary w-full">Save changes</button>
</form>
</div>
</div>
</x-user-layout>
@@ -0,0 +1,46 @@
<x-user-layout>
<x-slot name="title">Speakers</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Speakers</h1>
<p class="mt-1 text-sm text-slate-500">Manage speaker rosters and programme assignments for each event.</p>
</div>
<form method="GET" class="flex items-center gap-2">
<input type="text" name="search" value="{{ $search }}" placeholder="Search events…"
class="w-full max-w-sm rounded-xl border border-slate-200 px-3.5 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
<button type="submit" class="rounded-xl border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition">Search</button>
</form>
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@if($events->isEmpty())
<div class="px-6 py-14 text-center">
<p class="text-sm text-slate-500">{{ $search ? 'No events match your search.' : 'No events yet.' }}</p>
@unless($search)
<a href="{{ route('events.create') }}" class="mt-4 btn-primary">Create your first event</a>
@endunless
</div>
@else
<div class="divide-y divide-slate-100">
@foreach($events as $event)
@php $content = $event->content(); @endphp
<a href="{{ route('speakers.show', $event) }}" class="flex items-center gap-4 px-6 py-4 transition hover:bg-slate-50">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-violet-50 text-violet-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z"/></svg>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-slate-900">{{ $content['name'] ?? $event->label }}</p>
<p class="truncate text-xs text-slate-500">{{ $event->label }}</p>
</div>
<div class="hidden text-right text-xs text-slate-500 sm:block">
<p class="font-semibold text-slate-900">{{ number_format($event->speaker_count) }} speakers</p>
</div>
<svg class="h-4 w-4 shrink-0 text-slate-300" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
</a>
@endforeach
</div>
@endif
</div>
</div>
</x-user-layout>
+89
View File
@@ -0,0 +1,89 @@
<x-user-layout>
<x-slot name="title">Speakers {{ $qrCode->label }}</x-slot>
<div class="mx-auto max-w-3xl space-y-6">
@foreach(['success', 'error'] as $flash)
@if(session($flash))
<div class="rounded-xl border px-4 py-3 text-sm {{ $flash === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-700' : 'border-red-200 bg-red-50 text-red-700' }}">
{{ session($flash) }}
</div>
@endif
@endforeach
<div>
<a href="{{ route('speakers.index') }}" class="inline-flex items-center gap-1 text-sm text-slate-500 hover:text-slate-700">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
Speakers
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">Speaker management</h1>
<p class="mt-0.5 text-sm text-slate-500">{{ $qrCode->content()['name'] ?? $qrCode->label }}</p>
</div>
@if($programme)
<div class="rounded-2xl border border-indigo-100 bg-indigo-50/60 px-4 py-3 text-sm text-indigo-950">
Linked programme: <a href="{{ route('events.show', $programme) }}" class="font-semibold hover:underline">{{ $programme->label }}</a>.
Hosts assigned on programme sessions appear below and sync to virtual sessions.
</div>
@else
<div class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
Attach a programme on the <a href="{{ route('events.show', $qrCode) }}" class="font-semibold text-indigo-600 hover:underline">event settings</a> page to pull session hosts into this roster.
</div>
@endif
<form method="POST" action="{{ route('speakers.update', $qrCode) }}" class="space-y-5 rounded-2xl border border-slate-200 bg-white p-6"
x-data="{
speakers: {{ Illuminate\Support\Js::from(array_values(old('speakers', $roster ?: [['name' => '', 'email' => '', 'role' => '', 'bio' => '']]))) }},
addSpeaker() { this.speakers.push({ name: '', email: '', role: '', bio: '' }); },
removeSpeaker(i) { this.speakers.splice(i, 1); if (this.speakers.length === 0) this.addSpeaker(); },
}">
@csrf
@method('PUT')
<div class="flex items-center justify-between gap-3">
<div>
<h2 class="text-sm font-semibold text-slate-900">Event speaker roster</h2>
<p class="mt-0.5 text-xs text-slate-500">Add keynote speakers, panelists, and hosts. Use email when you want to invite them to virtual sessions.</p>
</div>
<button type="button" @click="addSpeaker()" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">+ Add speaker</button>
</div>
<div class="space-y-3">
<template x-for="(speaker, index) in speakers" :key="index">
<div class="rounded-xl border border-slate-200 p-4 space-y-3">
<div class="grid gap-3 sm:grid-cols-2">
<input type="text" :name="'speakers[' + index + '][name]'" x-model="speaker.name" placeholder="Name *"
class="rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
<input type="email" :name="'speakers[' + index + '][email]'" x-model="speaker.email" placeholder="Email (optional)"
class="rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
</div>
<input type="text" :name="'speakers[' + index + '][role]'" x-model="speaker.role" placeholder="Role / session (optional)"
class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30">
<textarea :name="'speakers[' + index + '][bio]'" x-model="speaker.bio" rows="2" placeholder="Short bio (optional)"
class="w-full rounded-xl border border-slate-200 px-3 py-2 text-sm focus:border-indigo-400 focus:outline-none focus:ring-1 focus:ring-indigo-400/30"></textarea>
<button type="button" @click="removeSpeaker(index)" class="text-xs font-medium text-red-600 hover:text-red-700">Remove</button>
</div>
</template>
</div>
<button type="submit" class="btn-primary w-full">Save speakers</button>
</form>
@if(count($programmeAssignments))
<div class="rounded-2xl border border-slate-200 bg-white p-6">
<h2 class="text-sm font-semibold text-slate-900">From programme schedule</h2>
<p class="mt-0.5 text-xs text-slate-500">These hosts are assigned on programme sessions. Edit them on the linked programme.</p>
<div class="mt-4 divide-y divide-slate-100">
@foreach($programmeAssignments as $assignment)
<div class="flex flex-wrap items-start justify-between gap-2 py-3 text-sm">
<div>
<p class="font-semibold text-slate-900">{{ $assignment['name'] }}</p>
<p class="text-xs text-slate-500">{{ $assignment['session'] ?: 'Session' }}@if($assignment['day']) · {{ $assignment['day'] }}@endif@if($assignment['time']) · {{ $assignment['time'] }}@endif</p>
</div>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-600">Programme</span>
</div>
@endforeach
</div>
</div>
@endif
</div>
</x-user-layout>
@@ -16,6 +16,8 @@
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />'],
['name' => 'Programmes', 'route' => route('programmes.index'), 'active' => request()->routeIs('programmes.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />'],
['name' => 'Speakers', 'route' => route('speakers.index'), 'active' => request()->routeIs('speakers.*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18.75a6 6 0 0 0 6-6v-1.5m-6 7.5a6 6 0 0 1-6-6v-1.5m6 7.5v3.75m-3.75 0h7.5M12 15.75a3 3 0 0 1-3-3V4.5a3 3 0 0 1 6 0v8.25a3 3 0 0 1-3 3Z" />'],
['name' => 'Payments & Payouts', 'route' => route('events.payouts'), 'active' => request()->routeIs('events.payouts'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 8.25h19.5M2.25 9h19.5m-16.5 5.25h6m-6 2.25h3m-3.75 3h15a2.25 2.25 0 0 0 2.25-2.25V6.75A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25v10.5A2.25 2.25 0 0 0 4.5 19.5Z" />'],
];
@@ -4,6 +4,15 @@
$evName = $c['name'] ?? $qrCode->label;
$isContribution = ($c['mode'] ?? 'ticketing') === 'contributions';
$badgeUrl = 'https://api.qrserver.com/v1/create-qr-code/?size=220x220&data=' . urlencode($registration->badge_code);
$joinUrl = '';
if (in_array($c['format'] ?? 'in_person', ['virtual', 'hybrid'], true)) {
foreach ($c['virtual_sessions'] ?? [] as $session) {
if (is_array($session) && filled($session['join_url'] ?? '')) {
$joinUrl = trim((string) $session['join_url']);
break;
}
}
}
@endphp
<!DOCTYPE html>
<html lang="en">
@@ -71,6 +80,14 @@
@else
<p class="mt-5 text-xs text-slate-400">Show this badge code at check-in.</p>
@endif
@if($joinUrl !== '')
<a href="{{ $joinUrl }}"
class="mt-6 inline-flex w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-bold text-white shadow-sm transition hover:opacity-90"
style="background:{{ $evColor }}">
Join webinar
</a>
@endif
@endif
</div>
</div>
+5
View File
@@ -6,6 +6,7 @@ use App\Http\Controllers\Events\AttendeeController;
use App\Http\Controllers\Events\OverviewController;
use App\Http\Controllers\Events\PayoutsController;
use App\Http\Controllers\Events\ProgrammeController;
use App\Http\Controllers\Events\SpeakerController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\EventRegistrationController;
use App\Http\Controllers\Public\QrScanController;
@@ -91,6 +92,10 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::get('/programmes', [ProgrammeController::class, 'index'])->name('programmes.index');
Route::get('/programmes/create', fn () => redirect()->route('events.create', ['type' => 'itinerary']))->name('programmes.create');
Route::get('/speakers', [SpeakerController::class, 'hub'])->name('speakers.index');
Route::get('/events/{event}/speakers', [SpeakerController::class, 'index'])->name('speakers.show');
Route::put('/events/{event}/speakers', [SpeakerController::class, 'update'])->name('speakers.update');
Route::get('/payouts', [PayoutsController::class, 'index'])->name('events.payouts');
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
@@ -0,0 +1,64 @@
<?php
namespace Tests\Feature;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class EventRegistrationConfirmationTest extends TestCase
{
use RefreshDatabase;
public function test_confirmed_page_renders_for_valid_registration(): void
{
$owner = User::create([
'public_id' => 'usr_reg_confirm',
'name' => 'Event Owner',
'email' => 'owner-reg-confirm@example.com',
]);
$event = QrCode::create([
'user_id' => $owner->id,
'short_code' => 'webinar-demo',
'type' => QrCode::TYPE_EVENT,
'label' => 'Product webinar',
'payload' => [
'content' => [
'name' => 'Product webinar',
'format' => 'virtual',
'virtual_sessions' => [
['join_url' => 'https://meet.ladill.com/r/demo-room'],
],
],
'style' => [],
],
'is_active' => true,
]);
QrEventRegistration::create([
'qr_code_id' => $event->id,
'user_id' => $owner->id,
'reference' => 'QRE-TESTREF123456',
'badge_code' => 'BADGE123',
'tier_name' => 'General admission',
'amount_minor' => 0,
'currency' => 'GHS',
'attendee_name' => 'Jane Doe',
'attendee_email' => 'jane@example.com',
'attendee_phone' => '+233200000000',
'status' => QrEventRegistration::STATUS_CONFIRMED,
]);
$this->get('/q/webinar-demo/registered/QRE-TESTREF123456', [
'X-Ladill-Internal' => '1',
])
->assertOk()
->assertSee('Jane Doe', false)
->assertSee('BADGE123', false)
->assertSee('Join webinar', false)
->assertSee('https://meet.ladill.com/r/demo-room', false);
}
}
@@ -0,0 +1,103 @@
<?php
namespace Tests\Feature;
use App\Models\QrCode;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class EventSpeakerManagementTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->withoutMiddleware(\App\Http\Middleware\EnsurePlatformSession::class);
Http::fake([
config('billing.api_url').'/balance*' => Http::response(['balance_minor' => 50000]),
]);
}
public function test_speakers_sidebar_hub_lists_events(): void
{
$owner = User::factory()->create(['public_id' => 'usr_speakers_hub']);
QrCode::create([
'user_id' => $owner->id,
'short_code' => 'evt-speakers-1',
'type' => QrCode::TYPE_EVENT,
'label' => 'Leadership summit',
'payload' => ['content' => ['name' => 'Leadership summit', 'speakers' => [['name' => 'Ada Lovelace', 'email' => '', 'role' => 'Keynote', 'bio' => '']]], 'style' => []],
'is_active' => true,
]);
$this->actingAs($owner)
->get(route('speakers.index'))
->assertOk()
->assertSee('Leadership summit', false)
->assertSee('Speakers', false);
}
public function test_event_speaker_roster_can_be_updated(): void
{
$owner = User::factory()->create(['public_id' => 'usr_speakers_upd']);
$event = QrCode::create([
'user_id' => $owner->id,
'short_code' => 'evt-speakers-2',
'type' => QrCode::TYPE_EVENT,
'label' => 'Dev day',
'payload' => ['content' => ['name' => 'Dev day'], 'style' => []],
'is_active' => true,
]);
$this->actingAs($owner)
->put(route('speakers.update', $event), [
'speakers' => [
['name' => 'Jane Doe', 'email' => 'jane@example.com', 'role' => 'Panelist', 'bio' => ''],
],
])
->assertRedirect(route('speakers.show', $event));
$event->refresh();
$this->assertSame('Jane Doe', $event->content()['speakers'][0]['name']);
$this->assertSame('jane@example.com', $event->content()['speakers'][0]['email']);
}
public function test_event_create_page_has_no_qr_design_wizard(): void
{
$owner = User::factory()->create(['public_id' => 'usr_evt_create_simple']);
$this->actingAs($owner)
->get(route('events.create'))
->assertOk()
->assertSee('Create event', false)
->assertSee('generated automatically', false)
->assertDontSee('Quick style presets', false)
->assertDontSee('Review &amp; create', false);
}
public function test_event_show_page_has_downloads_not_qr_customizer(): void
{
$owner = User::factory()->create(['public_id' => 'usr_evt_show_simple']);
$event = QrCode::create([
'user_id' => $owner->id,
'short_code' => 'evt-show-simple',
'type' => QrCode::TYPE_EVENT,
'label' => 'Simple show',
'payload' => ['content' => ['name' => 'Simple show'], 'style' => []],
'is_active' => true,
]);
$this->actingAs($owner)
->get(route('events.show', $event))
->assertOk()
->assertSee('Customize colors, logos, and frames in QR Plus.', false)
->assertSee('Programme outline', false)
->assertDontSee('Quick style presets', false);
}
}
+23
View File
@@ -42,4 +42,27 @@ class LegacyQrRedirectTest extends TestCase
$response->assertRedirect(LadillLink::url('demo-event'));
}
public function test_registration_paths_are_not_redirected_to_ladill_link(): void
{
$middleware = new RedirectLegacyQrToLadillLink;
foreach (['register', 'register/callback', 'registered/QRE-TESTREF123456'] as $suffix) {
$request = Request::create('/q/demo-event/'.$suffix, 'GET');
$request->setRouteResolver(function () {
return new class
{
public function parameter(string $name): ?string
{
return $name === 'shortCode' ? 'demo-event' : null;
}
};
});
$response = $middleware->handle($request, fn () => response('ok'));
$this->assertSame(200, $response->getStatusCode(), $suffix);
$this->assertSame('ok', $response->getContent(), $suffix);
}
}
}