Split attendees and badge printing; add a 3-step event create flow.
Deploy Ladill Events / deploy (push) Successful in 35s

Give each area its own sidebar entry and hub pages, and walk new events through details, QR design, then review before publish.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 12:27:42 +00:00
co-authored by Cursor
parent 51745193da
commit 5e00f319a5
12 changed files with 488 additions and 101 deletions
@@ -18,6 +18,93 @@ class AttendeeController extends Controller
{
public function __construct(private readonly SmsService $sms) {}
public function hub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
$event->confirmed_count = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count();
$event->checked_in_count = $event->eventRegistrations()
->whereNotNull('checked_in_at')
->count();
return $event;
});
return view('events.attendees-hub', [
'events' => $events,
'search' => $search,
]);
}
public function badgesHub(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('search', ''));
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->when($search !== '', fn ($q) => $q->where('label', 'like', "%{$search}%"))
->latest()
->get()
->map(function (QrCode $event) {
$event->printable_count = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count();
return $event;
});
return view('events.badges-hub', [
'events' => $events,
'search' => $search,
]);
}
public function badgePrinting(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$isContribution = ($event->content()['mode'] ?? 'ticketing') === 'contributions';
if ($isContribution) {
abort(404);
}
$query = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->latest();
if ($search = trim((string) $request->query('search', ''))) {
$query->where(function ($q) use ($search) {
$q->where('attendee_name', 'like', "%{$search}%")
->orWhere('attendee_email', 'like', "%{$search}%")
->orWhere('badge_code', 'like', "%{$search}%");
});
}
$registrations = $query->paginate(40)->withQueryString();
$badgeSize = $event->content()['badge_size'] ?? '4x3';
return view('events.badge-printing', [
'qrCode' => $event,
'event' => $event,
'registrations' => $registrations,
'badgeSize' => $badgeSize,
'printableCount' => $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count(),
]);
}
public function index(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
@@ -0,0 +1,49 @@
<x-user-layout>
<x-slot name="title">Attendees</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Attendees</h1>
<p class="mt-1 text-sm text-slate-500">Manage registrations, check-ins, and programme sharing 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 inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700 transition">
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('events.attendees', $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-indigo-50 text-indigo-600">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></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->confirmed_count) }} registered</p>
<p>{{ number_format($event->checked_in_count) }} checked in</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>
+27 -48
View File
@@ -2,14 +2,7 @@
@php $isContribution = ($qrCode->content()['mode'] ?? 'ticketing') === 'contributions'; @endphp
<x-slot name="title">{{ $isContribution ? 'Contributions' : 'Attendees' }} {{ $qrCode->label }}</x-slot>
<div class="mx-auto max-w-5xl space-y-6"
x-data="{
selected: [],
toggle(id) { const i = this.selected.indexOf(id); i === -1 ? this.selected.push(id) : this.selected.splice(i, 1); },
get qs() { return this.selected.map(id => 'ids[]=' + id).join('&'); },
printSelected() { window.open(@js(route('events.badges', $qrCode)) + '?auto=1&' + this.qs, '_blank'); },
zplSelected() { window.location = @js(route('events.badges.zpl', $qrCode)) + '?' + this.qs; }
}">
<div class="mx-auto max-w-5xl space-y-6">
{{-- Flash --}}
@foreach(['success', 'error'] as $flash)
@@ -21,12 +14,22 @@
@endforeach
{{-- Header --}}
<div>
<a href="{{ route('events.show', $qrCode) }}" 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>
{{ $qrCode->label }}
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $isContribution ? 'Contributions' : 'Attendees' }}</h1>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<a href="{{ route('attendees.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>
Attendees
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $isContribution ? 'Contributions' : 'Attendees' }}</h1>
<p class="mt-0.5 text-sm text-slate-500">{{ $qrCode->content()['name'] ?? $qrCode->label }}</p>
</div>
@unless($isContribution)
<a href="{{ route('events.badge-printing', $qrCode) }}"
class="inline-flex items-center justify-center gap-2 rounded-xl border border-violet-200 bg-violet-50 px-4 py-2.5 text-sm font-semibold text-violet-700 hover:bg-violet-100 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
Print badges
</a>
@endunless
</div>
{{-- Share programme outline --}}
@@ -73,30 +76,15 @@
</div>
{{-- Toolbar --}}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<form method="GET" class="flex items-center gap-2">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
class="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">
<select name="status" onchange="this.form.submit()" class="rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-600 focus:border-indigo-400 focus:outline-none">
<option value="">All</option>
<option value="confirmed" @selected(request('status') === 'confirmed')>Confirmed</option>
<option value="pending" @selected(request('status') === 'pending')>Pending</option>
</select>
</form>
@unless($isContribution)
<div class="flex flex-wrap items-center gap-2">
<span x-show="selected.length" x-cloak class="text-xs font-medium text-slate-500"><span x-text="selected.length"></span> selected</span>
<button type="button" x-show="selected.length" x-cloak @click="printSelected()"
class="rounded-xl bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-indigo-700 transition">Print selected</button>
<button type="button" x-show="selected.length" x-cloak @click="zplSelected()"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">ZPL selected</button>
<a href="{{ route('events.badges', $qrCode) }}?auto=1" target="_blank"
class="rounded-xl bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition">Print all badges</a>
<a href="{{ route('events.badges.zpl', $qrCode) }}"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">Download ZPL</a>
</div>
@endunless
</div>
<form method="GET" class="flex items-center gap-2">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
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">
<select name="status" onchange="this.form.submit()" class="rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-600 focus:border-indigo-400 focus:outline-none">
<option value="">All</option>
<option value="confirmed" @selected(request('status') === 'confirmed')>Confirmed</option>
<option value="pending" @selected(request('status') === 'pending')>Pending</option>
</select>
</form>
{{-- Table --}}
<div class="overflow-hidden rounded-2xl border border-slate-200 bg-white">
@@ -104,7 +92,6 @@
<table class="min-w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50/70 text-left text-xs uppercase tracking-wide text-slate-400">
<tr>
@unless($isContribution)<th class="px-4 py-3 w-8"></th>@endunless
<th class="px-4 py-3">{{ $isContribution ? 'Contributor' : 'Attendee' }}</th>
<th class="px-4 py-3">{{ $isContribution ? 'Category' : 'Ticket' }}</th>
@unless($isContribution)<th class="px-4 py-3">Badge</th>@endunless
@@ -115,14 +102,6 @@
<tbody class="divide-y divide-slate-100">
@forelse($registrations as $reg)
<tr class="hover:bg-slate-50/60">
@unless($isContribution)
<td class="px-4 py-3">
@if($reg->status === \App\Models\QrEventRegistration::STATUS_CONFIRMED)
<input type="checkbox" :checked="selected.includes({{ $reg->id }})" @change="toggle({{ $reg->id }})"
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
@endif
</td>
@endunless
<td class="px-4 py-3">
<p class="font-semibold text-slate-800">{{ $reg->attendee_name }}</p>
<p class="text-xs text-slate-400">{{ $reg->attendee_email }}</p>
@@ -154,7 +133,7 @@
@endunless
</tr>
@empty
<tr><td colspan="{{ $isContribution ? 3 : 6 }}" class="px-4 py-12 text-center text-sm text-slate-400">{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}</td></tr>
<tr><td colspan="{{ $isContribution ? 3 : 5 }}" class="px-4 py-12 text-center text-sm text-slate-400">{{ $isContribution ? 'No contributions yet.' : 'No registrations yet.' }}</td></tr>
@endforelse
</tbody>
</table>
@@ -0,0 +1,89 @@
<x-user-layout>
<x-slot name="title">Badge printing {{ $qrCode->label }}</x-slot>
<div class="mx-auto max-w-5xl space-y-6"
x-data="{
selected: [],
toggle(id) { const i = this.selected.indexOf(id); i === -1 ? this.selected.push(id) : this.selected.splice(i, 1); },
toggleAll(ids) { this.selected = this.selected.length === ids.length ? [] : [...ids]; },
get qs() { return this.selected.map(id => 'ids[]=' + id).join('&'); },
printSelected() { window.open(@js(route('events.badges', $qrCode)) + '?auto=1&' + this.qs, '_blank'); },
zplSelected() { window.location = @js(route('events.badges.zpl', $qrCode)) + '?' + this.qs; }
}">
<div>
<a href="{{ route('badges.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>
Badge printing
</a>
<h1 class="mt-1.5 text-2xl font-bold text-slate-900">{{ $qrCode->content()['name'] ?? $qrCode->label }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ number_format($printableCount) }} confirmed attendees · {{ strtoupper($badgeSize) }} badges</p>
</div>
<div class="flex flex-col gap-3 rounded-2xl border border-slate-200 bg-white p-4 sm:flex-row sm:items-center sm:justify-between">
<div class="flex flex-wrap items-center gap-2">
<span x-show="selected.length" x-cloak class="text-xs font-medium text-slate-500"><span x-text="selected.length"></span> selected</span>
<button type="button" x-show="selected.length" x-cloak @click="printSelected()"
class="rounded-xl bg-indigo-600 px-3.5 py-2 text-xs font-semibold text-white hover:bg-indigo-700 transition">Print selected</button>
<button type="button" x-show="selected.length" x-cloak @click="zplSelected()"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">ZPL selected</button>
<a href="{{ route('events.badges', $qrCode) }}?auto=1" target="_blank"
class="rounded-xl bg-slate-900 px-3.5 py-2 text-xs font-semibold text-white hover:bg-slate-800 transition">Print all badges</a>
<a href="{{ route('events.badges.zpl', $qrCode) }}"
class="rounded-xl border border-slate-200 px-3.5 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-50 transition">Download ZPL</a>
</div>
<a href="{{ route('events.attendees', $qrCode) }}" class="text-xs font-semibold text-indigo-600 hover:text-indigo-800">Manage attendees </a>
</div>
<form method="GET" class="flex items-center gap-2">
<input type="text" name="search" value="{{ request('search') }}" placeholder="Search name, email, code…"
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">
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead class="border-b border-slate-100 bg-slate-50/70 text-left text-xs uppercase tracking-wide text-slate-400">
<tr>
<th class="px-4 py-3 w-8">
@if($registrations->isNotEmpty())
<input type="checkbox"
@change="toggleAll(@js($registrations->pluck('id')->all()))"
:checked="selected.length === @js($registrations->count()) && selected.length > 0"
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
@endif
</th>
<th class="px-4 py-3">Attendee</th>
<th class="px-4 py-3">Ticket</th>
<th class="px-4 py-3">Badge code</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-100">
@forelse($registrations as $reg)
<tr class="hover:bg-slate-50/60">
<td class="px-4 py-3">
<input type="checkbox" :checked="selected.includes({{ $reg->id }})" @change="toggle({{ $reg->id }})"
class="h-4 w-4 rounded border-slate-300 text-indigo-600 focus:ring-indigo-500">
</td>
<td class="px-4 py-3">
<p class="font-semibold text-slate-800">{{ $reg->attendee_name }}</p>
<p class="text-xs text-slate-400">{{ $reg->attendee_email }}</p>
</td>
<td class="px-4 py-3">
<span class="font-medium text-slate-700">{{ $reg->tier_name }}</span>
</td>
<td class="px-4 py-3"><code class="rounded bg-slate-100 px-2 py-0.5 text-xs">{{ $reg->badge_code }}</code></td>
</tr>
@empty
<tr><td colspan="4" class="px-4 py-12 text-center text-sm text-slate-400">No confirmed attendees to print yet.</td></tr>
@endforelse
</tbody>
</table>
</div>
@if($registrations->hasPages())
<div class="border-t border-slate-100 px-4 py-3">{{ $registrations->links() }}</div>
@endif
</div>
</div>
</x-user-layout>
@@ -0,0 +1,54 @@
<x-user-layout>
<x-slot name="title">Badge printing</x-slot>
<div class="mx-auto max-w-5xl space-y-6">
<div>
<h1 class="text-2xl font-bold text-slate-900">Badge printing</h1>
<p class="mt-1 text-sm text-slate-500">Select attendees and print badges or download ZPL for label printers.</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 inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-indigo-700 transition">
Create your first event
</a>
@endunless
</div>
@else
<div class="divide-y divide-slate-100">
@foreach($events as $event)
@php
$content = $event->content();
$isContribution = ($content['mode'] ?? 'ticketing') === 'contributions';
@endphp
@if($isContribution)
@continue
@endif
<a href="{{ route('events.badge-printing', $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="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
</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">Badge size: {{ strtoupper($content['badge_size'] ?? '4x3') }}</p>
</div>
<div class="hidden text-right text-xs text-slate-500 sm:block">
<p class="font-semibold text-slate-900">{{ number_format($event->printable_count) }} printable</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>
+1 -1
View File
@@ -66,7 +66,7 @@
'homeUrl' => route('events.dashboard'),
'homeActive' => request()->routeIs('events.dashboard'),
'searchUrl' => route('events.index'),
'searchActive' => request()->routeIs('events.index') || request()->routeIs('events.show') || request()->routeIs('events.attendees*'),
'searchActive' => request()->routeIs('events.index') || request()->routeIs('events.show') || request()->routeIs('attendees.*') || request()->routeIs('events.attendees*') || request()->routeIs('badges.*') || request()->routeIs('events.badge-printing'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
+5 -1
View File
@@ -8,8 +8,12 @@
$main = [
['name' => 'Overview', 'route' => route('events.dashboard'), 'active' => request()->routeIs('events.dashboard'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12 11.2 3.05c.44-.44 1.15-.44 1.59 0L21.75 12M4.5 9.75v10.5a.75.75 0 0 0 .75.75H9.75v-6a.75.75 0 0 1 .75-.75h3a.75.75 0 0 1 .75.75v6h4.5a.75.75 0 0 0 .75-.75V9.75" />'],
['name' => 'Events', 'route' => route('events.index'), 'active' => request()->routeIs('events.index') || request()->routeIs('events.create') || request()->routeIs('events.show') || request()->routeIs('events.attendees*') || request()->routeIs('events.badges*'),
['name' => 'Events', 'route' => route('events.index'), 'active' => request()->routeIs('events.index') || request()->routeIs('events.create') || request()->routeIs('events.show'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5" />'],
['name' => 'Attendees', 'route' => route('attendees.index'), 'active' => request()->routeIs('attendees.*') || request()->routeIs('events.attendees*'),
'icon' => '<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />'],
['name' => 'Badge printing', 'route' => route('badges.index'), 'active' => request()->routeIs('badges.*') || request()->routeIs('events.badge-printing') || request()->routeIs('events.badges*'),
'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' => 'Payments & Payouts', 'route' => route('events.payouts'), 'active' => request()->routeIs('events.payouts'),
+112 -45
View File
@@ -16,6 +16,7 @@
price: @js((float) $pricePerQr),
topupUrl: @js($topupUrl),
type: @js($createType),
wizard: @js(! $isProgramme),
})">
@if(session('error'))
@@ -40,25 +41,53 @@
{{ $isProgramme ? 'Programmes' : 'Events' }}
</a>
<h1 class="mt-2 text-2xl font-bold text-slate-900">{{ $isProgramme ? 'New programme' : 'Create event' }}</h1>
<p class="mt-1 text-sm text-slate-500">{{ $isProgramme ? 'Build a schedule outline and share it with attendees.' : 'Set up ticketing, registration, and your event QR.' }}</p>
<p class="mt-1 text-sm text-slate-500">
{{ $isProgramme ? 'Build a schedule outline and share it with attendees.' : 'Set up your event, then design the QR attendees will scan.' }}
</p>
</div>
@unless($isProgramme)
{{-- Event creation wizard steps --}}
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-4 sm:px-6">
<ol class="flex items-center justify-between gap-2">
@foreach(['Event details', 'QR code', 'Review & create'] as $i => $label)
@php $n = $i + 1; @endphp
<li class="flex min-w-0 flex-1 items-center gap-2">
<span class="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-xs font-bold transition"
:class="step >= {{ $n }} ? 'bg-indigo-600 text-white' : 'bg-slate-100 text-slate-400'">{{ $n }}</span>
<span class="hidden truncate text-xs font-semibold sm:block"
:class="step >= {{ $n }} ? 'text-slate-900' : 'text-slate-400'">{{ $label }}</span>
@if($n < 3)
<span class="mx-1 hidden h-px flex-1 bg-slate-200 sm:block"></span>
@endif
</li>
@endforeach
</ol>
</div>
@endunless
<form x-ref="createForm" action="{{ route('events.store') }}" method="POST" enctype="multipart/form-data"
class="grid grid-cols-1 gap-8 lg:grid-cols-[380px_1fr]"
@submit="submitOrTopup($event)">
class="grid grid-cols-1 gap-8"
:class="(!wizard || step >= 2) ? 'lg:grid-cols-[380px_1fr]' : ''"
@submit="wizard ? wizardSubmit($event) : submitOrTopup($event)">
@csrf
<input type="hidden" name="type" value="{{ $createType }}">
{{-- Left: Sticky preview (desktop only) --}}
<div class="hidden lg:block lg:sticky lg:top-6 lg:self-start">
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
{{-- QR preview (step 2 for events, always for programmes) --}}
<div class="lg:sticky lg:top-6 lg:self-start"
x-show="!wizard || step === 2 || step === 3"
x-cloak
:class="(!wizard || step >= 2) ? 'block' : 'hidden'">
<div class="hidden lg:block">
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
</div>
</div>
{{-- Right: Controls --}}
<div class="space-y-5 pb-4 lg:pb-0">
<div class="space-y-5 pb-4 lg:pb-0"
:class="wizard && step >= 2 ? 'lg:col-start-2' : (wizard && step === 1 ? 'lg:col-span-full' : '')">
{{-- Content first --}}
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
{{-- Step 1 / Programme: Event details --}}
<div x-show="!wizard || step === 1" class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">{{ $isProgramme ? 'Programme details' : 'Event details' }}</h2>
<p class="mt-0.5 text-xs text-slate-400">{{ $isProgramme ? 'Title, schedule, and venue for this outline.' : 'Name, dates, ticketing, and registration settings.' }}</p>
@@ -72,7 +101,6 @@
placeholder="{{ $isProgramme ? 'e.g. Annual conference programme' : 'e.g. Tech summit 2026' }}">
</div>
{{-- Custom link --}}
<div x-data="{
slug: @js(old('custom_short_code', '')),
status: '',
@@ -109,27 +137,16 @@
placeholder="my-brand">
</div>
<div class="mt-1.5 flex items-center gap-1.5 text-[11px]">
<template x-if="status === 'checking'">
<span class="text-slate-400">Checking…</span>
</template>
<template x-if="status === 'checking'"><span class="text-slate-400">Checking…</span></template>
<template x-if="status === 'available'">
<span class="flex items-center gap-1 text-green-600">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5"/></svg>
Available your link will be <span x-text="baseUrl + '/' + slug.trim()" class="font-medium text-green-700"></span>
Available
</span>
</template>
<template x-if="status === 'taken'">
<span class="flex items-center gap-1 text-red-500">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
Already taken choose another
</span>
</template>
<template x-if="status === 'invalid'">
<span class="text-red-500">320 chars, letters, numbers and hyphens only (no leading/trailing hyphens)</span>
</template>
<template x-if="status === ''">
<span class="text-slate-400">Leave blank to auto-generate</span>
</template>
<template x-if="status === 'taken'"><span class="text-red-500">Already taken</span></template>
<template x-if="status === 'invalid'"><span class="text-red-500">Invalid format</span></template>
<template x-if="status === ''"><span class="text-slate-400">Leave blank to auto-generate</span></template>
</div>
</div>
@@ -137,28 +154,72 @@
@include('qr-codes.partials.type-fields-create')
</div>
@unless($isProgramme)
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
You will design the QR code in the next step. Details can be updated anytime without reprinting.
</p>
@else
<p class="rounded-xl bg-slate-50 px-3.5 py-2.5 text-xs leading-5 text-slate-400">
Attendees scan your Ladill event link update details anytime without reprinting the QR.
</p>
@endunless
</div>
</div>
{{-- Customization --}}
@include('qr-codes.partials.customization-fields', [
'style' => $defaultStyle,
'moduleStyles' => $moduleStyles,
'cornerOuterStyles' => $cornerOuterStyles,
'cornerInnerStyles' => $cornerInnerStyles,
'frameStyles' => $frameStyles,
])
{{-- Step 2: QR customization (events) or inline (programmes) --}}
<div x-show="!wizard || step === 2">
@include('qr-codes.partials.customization-fields', [
'style' => $defaultStyle,
'moduleStyles' => $moduleStyles,
'cornerOuterStyles' => $cornerOuterStyles,
'cornerInnerStyles' => $cornerInnerStyles,
'frameStyles' => $frameStyles,
])
</div>
{{-- Desktop submit button --}}
<button type="button"
@click="submitOrTopup($event)"
class="hidden lg:block w-full rounded-xl bg-indigo-600 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2">
{{ $isProgramme ? 'Create programme' : 'Create event' }}
</button>
{{-- Step 3: Review & create (events only) --}}
<div x-show="wizard && step === 3" x-cloak class="space-y-5">
<div class="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
<div class="border-b border-slate-100 bg-slate-50/70 px-5 py-4">
<h2 class="text-sm font-semibold text-slate-900">Review your event</h2>
<p class="mt-0.5 text-xs text-slate-400">Confirm details before publishing. GHS {{ number_format($pricePerQr, 2) }} will be charged from your QR balance.</p>
</div>
<div class="space-y-3 p-5 text-sm">
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
<span class="text-slate-500">Event</span>
<span class="font-semibold text-slate-900 text-right" x-text="reviewEventName()"></span>
</div>
<div class="flex justify-between gap-4 border-b border-slate-100 pb-3">
<span class="text-slate-500">Admin label</span>
<span class="font-semibold text-slate-900 text-right" x-text="reviewLabel()"></span>
</div>
<div class="flex justify-between gap-4">
<span class="text-slate-500">QR balance after</span>
<span class="font-semibold text-slate-900">GHS <span x-text="Math.max(0, balance - price).toFixed(2)"></span></span>
</div>
</div>
</div>
<div class="lg:hidden">
@include('qr-codes.partials.qr-preview-card', ['showDownloads' => false])
</div>
</div>
{{-- Desktop navigation --}}
<div class="hidden gap-3 lg:flex">
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
class="rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-600 hover:bg-slate-50 transition">
Back
</button>
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
class="flex-1 rounded-xl bg-indigo-600 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700">
Continue
</button>
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
class="flex-1 rounded-xl bg-indigo-600 py-3 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700">
{{ $isProgramme ? 'Create programme' : 'Create event' }}
</button>
</div>
</div>
</form>
@@ -166,8 +227,11 @@
<div x-show="!showPreviewModal"
class="mobile-action-bar lg:hidden">
<div class="mobile-action-bar__toolbar">
<button type="button"
@click="showPreviewModal = true"
<button type="button" x-show="wizard && step > 1" x-cloak @click="prevStep()"
class="rounded-xl border border-slate-200 px-4 py-2.5 text-sm font-semibold text-slate-600">
Back
</button>
<button type="button" x-show="wizard && (step === 2 || step === 3)" @click="showPreviewModal = true"
class="flex flex-1 items-center justify-center gap-2 rounded-xl border border-slate-200 bg-slate-50 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"/>
@@ -175,8 +239,11 @@
</svg>
Preview
</button>
<button type="button"
@click="submitOrTopup($event)"
<button type="button" x-show="wizard && step < 3" x-cloak @click="nextStep()"
class="flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700">
Continue
</button>
<button type="button" x-show="!wizard || step === 3" @click="wizardSubmit($event)"
class="flex flex-1 items-center justify-center gap-2 rounded-xl bg-indigo-600 py-2.5 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700">
Create
</button>
@@ -21,6 +21,8 @@
csrf: config.csrf ?? null,
_canonicalSynced: false,
type: config.type ?? 'url',
wizard: Boolean(config.wizard),
step: config.wizard ? 1 : 0,
fg: config.style.foreground,
bg: config.style.background,
ecc: config.style.error_correction,
@@ -252,6 +254,50 @@
this.redirectTopup();
}
},
nextStep(event) {
if (!this.wizard) {
return;
}
if (this.step === 1 && !this.validateWizardStep1()) {
event?.preventDefault();
return;
}
if (this.step < 3) {
this.step++;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
prevStep() {
if (this.wizard && this.step > 1) {
this.step--;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
},
validateWizardStep1() {
const form = this.$refs.createForm;
const label = form?.querySelector('[name=label]');
if (!label?.value?.trim()) {
label?.focus();
label?.reportValidity?.();
return false;
}
return label.checkValidity?.() !== false;
},
wizardSubmit(event) {
if (this.wizard && this.step < 3) {
event?.preventDefault();
this.nextStep(event);
return;
}
this.submitOrTopup(event);
},
reviewLabel() {
return this.$refs.createForm?.querySelector('[name=label]')?.value?.trim() || '';
},
reviewEventName() {
const eventBlock = this.$refs.createForm?.querySelector('[data-event-name]');
return eventBlock?.value?.trim() || this.reviewLabel();
},
submitOrTopup(event) {
if (this.balance <= 0 || this.balance < this.price) {
event?.preventDefault();
@@ -393,7 +393,7 @@
removeField(i) { this.badgeFields.splice(i, 1); }
}">
<div class="grid gap-3">
<input type="text" name="name" value="{{ old('name') }}" placeholder="Event name *"
<input type="text" name="name" value="{{ old('name') }}" placeholder="Event name *" data-event-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">
<input type="text" name="tagline" value="{{ old('tagline') }}" placeholder="Tagline (e.g. Africa's biggest dev conference)"
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">
@@ -597,11 +597,20 @@
</div>
@if (Route::has('events.attendees'))
<a href="{{ route('events.attendees', $qrCode) }}"
class="flex items-center justify-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm font-semibold text-indigo-700 hover:bg-indigo-100 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
Manage attendees &amp; print badges
</a>
<div class="grid gap-2 sm:grid-cols-2">
<a href="{{ route('events.attendees', $qrCode) }}"
class="flex items-center justify-center gap-2 rounded-xl border border-indigo-200 bg-indigo-50 px-4 py-3 text-sm font-semibold text-indigo-700 hover:bg-indigo-100 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z"/></svg>
Manage attendees
</a>
@if (Route::has('events.badge-printing') && ($c['mode'] ?? 'ticketing') !== 'contributions')
<a href="{{ route('events.badge-printing', $qrCode) }}"
class="flex items-center justify-center gap-2 rounded-xl border border-violet-200 bg-violet-50 px-4 py-3 text-sm font-semibold text-violet-700 hover:bg-violet-100 transition">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15c1.012 0 1.867.668 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z"/></svg>
Print badges
</a>
@endif
</div>
@endif
</div>
+3
View File
@@ -55,7 +55,10 @@ Route::middleware(['auth'])->group(function () {
Route::get('/events/{event}/preview.png', [QrCodeController::class, 'preview'])->name('events.preview');
Route::get('/events/{event}/download/{format}', [QrCodeController::class, 'download'])->name('events.download')->whereIn('format', ['png', 'svg', 'pdf']);
Route::get('/attendees', [AttendeeController::class, 'hub'])->name('attendees.index');
Route::get('/badges', [AttendeeController::class, 'badgesHub'])->name('badges.index');
Route::get('/events/{event}/attendees', [AttendeeController::class, 'index'])->name('events.attendees');
Route::get('/events/{event}/badge-printing', [AttendeeController::class, 'badgePrinting'])->name('events.badge-printing');
Route::get('/events/{event}/badges', [AttendeeController::class, 'badges'])->name('events.badges');
Route::get('/events/{event}/badges.zpl', [AttendeeController::class, 'badgesZpl'])->name('events.badges.zpl');
Route::patch('/events/{event}/attendees/{registration}/check-in', [AttendeeController::class, 'checkIn'])->name('events.attendees.check-in');