Extract Ladill Events as a standalone events suite at events.ladill.com.
Deploy Ladill QR Plus / deploy (push) Successful in 28s

Full control center for ticketed events, contributions, attendees, badges,
and programmes — not a QR utility clone. Includes SSO shell, import command,
and platform cutover runbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 09:39:53 +00:00
co-authored by Cursor
commit d8dbc83e2d
246 changed files with 34279 additions and 0 deletions
@@ -0,0 +1,173 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Notifications\EventProgrammeSharedNotification;
use App\Services\Billing\SmsService;
use App\Support\Events\EventBadgeZpl;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Notification;
use Illuminate\View\View;
class AttendeeController extends Controller
{
public function __construct(private readonly SmsService $sms) {}
public function index(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$query = $event->eventRegistrations()->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}%");
});
}
if ($status = $request->query('status')) {
$query->where('status', $status);
}
$registrations = $query->paginate(40)->withQueryString();
$stats = [
'total' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->count(),
'checked_in' => $event->eventRegistrations()->whereNotNull('checked_in_at')->count(),
'revenue' => $event->eventRegistrations()->where('status', QrEventRegistration::STATUS_CONFIRMED)->sum('amount_minor') / 100,
];
$programme = $this->programmeFor($event);
return view('events.attendees', [
'qrCode' => $event,
'event' => $event,
'registrations' => $registrations,
'stats' => $stats,
'programme' => $programme,
]);
}
public function shareProgramme(QrCode $event): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$programme = $this->programmeFor($event);
if (! $programme) {
return back()->with('error', 'No programme outline is attached to this event yet.');
}
$recipients = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->get();
$eventName = $event->content()['name'] ?? $event->label;
$programmeUrl = $programme->publicUrl();
$emailed = 0;
$texted = 0;
foreach ($recipients as $reg) {
if ($reg->attendee_email) {
Notification::route('mail', $reg->attendee_email)
->notify(new EventProgrammeSharedNotification($event, $programme, $reg->attendee_name));
$emailed++;
}
if ($reg->attendee_phone) {
$this->sms->send(
$reg->attendee_phone,
sprintf(
'Hi %s, the programme for %s is here: %s',
explode(' ', $reg->attendee_name ?? 'there')[0],
$eventName,
$programmeUrl
)
);
$texted++;
}
}
if ($emailed === 0 && $texted === 0) {
return back()->with('error', 'No confirmed attendees with contact details to share with yet.');
}
return back()->with('success', "Programme shared — {$emailed} email(s) and {$texted} SMS sent.");
}
private function programmeFor(QrCode $event): ?QrCode
{
$programmeId = (int) ($event->content()['programme_qr_id'] ?? 0);
if ($programmeId <= 0) {
return null;
}
return QrCode::where('id', $programmeId)
->where('user_id', $event->user_id)
->where('type', QrCode::TYPE_ITINERARY)
->first();
}
public function badges(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$registrations = $this->selectedRegistrations($request, $event);
return view('events.badges', [
'qrCode' => $event,
'event' => $event,
'registrations' => $registrations,
'auto' => $request->boolean('auto'),
]);
}
public function badgesZpl(Request $request, QrCode $event): Response
{
$this->authorize('view', $event);
abort_unless($event->type === QrCode::TYPE_EVENT, 404);
$registrations = $this->selectedRegistrations($request, $event);
$zpl = EventBadgeZpl::forRegistrations($event, $registrations);
return response($zpl, 200, [
'Content-Type' => 'application/octet-stream',
'Content-Disposition' => 'attachment; filename="badges-' . $event->short_code . '.zpl"',
]);
}
public function checkIn(QrCode $event, QrEventRegistration $registration): RedirectResponse
{
$this->authorize('view', $event);
abort_unless($registration->qr_code_id === $event->id, 404);
$registration->update([
'checked_in_at' => $registration->checked_in_at ? null : now(),
]);
return back();
}
/** @return \Illuminate\Support\Collection<int, QrEventRegistration> */
private function selectedRegistrations(Request $request, QrCode $event)
{
$query = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->latest();
$ids = array_filter((array) $request->query('ids', []));
if (! empty($ids)) {
$query->whereIn('id', $ids);
}
return $query->get();
}
}
@@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class OverviewController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(Request $request): View
{
$account = ladill_account();
$events = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->get();
$eventIds = $events->pluck('id');
$upcomingCount = $events->filter(function (QrCode $event) {
$startsAt = $event->content()['starts_at'] ?? null;
if (! $startsAt) {
return true;
}
return strtotime((string) $startsAt) >= now()->startOfDay()->timestamp;
})->count();
$registrationsQuery = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED);
$ticketsSold = (clone $registrationsQuery)->count();
$revenueMinor = (int) (clone $registrationsQuery)->sum('amount_minor');
$checkedIn = QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->whereNotNull('checked_in_at')
->count();
$checkInRate = $ticketsSold > 0 ? round(($checkedIn / $ticketsSold) * 100) : 0;
$recentEvents = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->latest()
->limit(5)
->get()
->map(function (QrCode $event) {
$event->confirmed_count = $event->eventRegistrations()
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->count();
return $event;
});
$programmeCount = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Events dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.dashboard', [
'upcomingCount' => $upcomingCount,
'ticketsSold' => $ticketsSold,
'revenueMinor' => $revenueMinor,
'checkInRate' => $checkInRate,
'recentEvents' => $recentEvents,
'programmeCount' => $programmeCount,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Billing\BillingClient;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use Throwable;
class PayoutsController extends Controller
{
public function __construct(private BillingClient $billing) {}
public function index(): View
{
$account = ladill_account();
$eventIds = $account->qrCodes()
->where('type', QrCode::TYPE_EVENT)
->pluck('id');
$revenueMinor = (int) QrEventRegistration::query()
->whereIn('qr_code_id', $eventIds)
->where('status', QrEventRegistration::STATUS_CONFIRMED)
->sum('amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Events payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('events.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Events;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\Request;
use Illuminate\View\View;
class ProgrammeController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$programmes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::programmeTypes())
->latest()
->get();
return view('events.programmes', compact('programmes'));
}
}