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
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class MeController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
$user = $request->user();
$account = ladill_account();
return response()->json([
'data' => [
'id' => $user->id,
'public_id' => $user->public_id,
'name' => $user->name,
'email' => $user->email,
'acting_account' => [
'id' => $account->id,
'public_id' => $account->public_id,
'name' => $account->name,
'email' => $account->email,
],
'accessible_account_ids' => $user->accessibleAccounts()->pluck('id')->values(),
],
]);
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use RuntimeException;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrAnalyticsService $analytics,
) {}
public function index(Request $request): JsonResponse
{
$codes = $this->accountQuery()
->latest()
->get()
->map(fn (QrCode $code) => $this->present($code));
return response()->json(['data' => $codes]);
}
public function show(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json(['data' => $this->present($qrCode, detailed: true)]);
}
public function analytics(QrCode $qrCode): JsonResponse
{
$this->ensurePlusCode($qrCode);
$this->authorize('view', $qrCode);
return response()->json([
'data' => [
'summary' => $this->analytics->summaryFor($qrCode),
'daily_scans' => $this->analytics->dailyScans($qrCode, 30)->values(),
'devices' => $this->analytics->breakdown($qrCode, 'device_type'),
'browsers' => $this->analytics->breakdown($qrCode, 'browser'),
],
]);
}
public function store(Request $request): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:'.implode(',', QrTypeCatalog::keys())],
'destination_url' => ['nullable', 'url', 'max:2048'],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT) {
return response()->json([
'message' => 'PDF QR codes must be created in the QR Plus app (file upload required).',
], 422);
}
try {
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
$validated,
));
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($qrCode->fresh(), detailed: true)], 201);
}
public function update(Request $request, QrCode $qrCode): JsonResponse
{
abort_unless($request->user()->tokenCan('qr:write'), 403, 'Token requires qr:write ability.');
$this->ensurePlusCode($qrCode);
$this->authorize('update', $qrCode);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'is_active' => ['sometimes', 'boolean'],
]);
if ($qrCode->isDocumentType() && $request->hasFile('document')) {
return response()->json([
'message' => 'Replace PDF files in the QR Plus app.',
], 422);
}
try {
$updated = $this->manager->update($qrCode, $request->all());
} catch (RuntimeException $e) {
return response()->json(['message' => $e->getMessage()], 422);
}
return response()->json(['data' => $this->present($updated->fresh(), detailed: true)]);
}
/** @return \Illuminate\Database\Eloquent\Builder<QrCode> */
private function accountQuery()
{
return QrCode::query()
->where('user_id', ladill_account()->id)
->whereIn('type', QrTypeCatalog::eventTypes());
}
private function ensurePlusCode(QrCode $qrCode): void
{
abort_unless(
$qrCode->user_id === ladill_account()->id && QrTypeCatalog::isValid($qrCode->type),
404,
);
}
/** @return array<string, mixed> */
private function present(QrCode $qrCode, bool $detailed = false): array
{
$data = [
'id' => $qrCode->id,
'label' => $qrCode->label,
'type' => $qrCode->type,
'type_label' => $qrCode->typeLabel(),
'short_code' => $qrCode->short_code,
'public_url' => $qrCode->publicUrl(),
'is_active' => $qrCode->is_active,
'scans_total' => $qrCode->scans_total,
'unique_scans_total' => $qrCode->unique_scans_total,
'last_scanned_at' => $qrCode->last_scanned_at?->toIso8601String(),
'created_at' => $qrCode->created_at?->toIso8601String(),
'updated_at' => $qrCode->updated_at?->toIso8601String(),
];
if ($detailed) {
$data['destination_url'] = $qrCode->destination_url;
$data['content'] = $qrCode->content();
}
return $data;
}
}
@@ -0,0 +1,180 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
/**
* "Sign in with Ladill" Authorization Code + PKCE against auth.ladill.com.
* Establishes a local session + thin user mirror keyed by the OIDC `sub`.
*/
class SsoLoginController extends Controller
{
public function connect(Request $request): RedirectResponse
{
$intended = (string) $request->query('redirect', route('events.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('events.dashboard'));
}
$verifier = Str::random(64);
$state = Str::random(40);
$request->session()->put('sso.verifier', $verifier);
$request->session()->put('sso.state', $state);
$request->session()->put('sso.intended', $intended);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
$query = [
'response_type' => 'code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'scope' => 'openid profile email',
'state' => $state,
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
];
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
return redirect()->away(rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query));
}
public function callback(Request $request): RedirectResponse
{
$intended = (string) $request->session()->get('sso.intended', route('events.dashboard'));
if ($request->filled('error')) {
if (in_array($request->query('error'), ['login_required', 'interaction_required', 'consent_required'], true)
&& ! $request->boolean('interactive')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'redirect_uri' => (string) config('services.ladill_sso.redirect'),
'code' => (string) $request->query('code'),
'code_verifier' => (string) $request->session()->pull('sso.verifier'),
]);
if ($tokenRes->failed()) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
$user = User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $claims->json('email') ?: (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->safeRedirect($intended, route('events.dashboard'));
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$home = 'https://'.config('app.platform_domain');
$endSession = 'https://'.config('app.auth_domain').'/logout/sso?redirect='.urlencode($home);
return redirect()->away($endSession);
}
public function frontchannelLogout(Request $request): Response|RedirectResponse
{
if ($this->shouldLogoutForMailbox($request)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
}
$return = (string) $request->query('return', '');
$root = (string) config('app.platform_domain', 'ladill.com');
$host = parse_url($return, PHP_URL_HOST);
if (str_starts_with($return, 'https://') && is_string($host) && ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($return);
}
return response('', 204);
}
private function shouldLogoutForMailbox(Request $request): bool
{
$mailbox = strtolower(trim((string) $request->query('mailbox', '')));
if ($mailbox === '' || ! str_contains($mailbox, '@')) {
return true;
}
$user = Auth::user();
if (! $user) {
return false;
}
return strtolower((string) $user->email) !== $mailbox;
}
private function safeRedirect(string $url, string $fallback): RedirectResponse
{
$host = parse_url($url, PHP_URL_HOST);
$root = (string) config('app.platform_domain', 'ladill.com');
if (is_string($host) && str_starts_with($url, 'https://')
&& ($host === $root || str_ends_with($host, '.'.$root))) {
return redirect()->away($url);
}
return redirect()->away($fallback);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
abstract class Controller
{
use AuthorizesRequests;
}
@@ -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'));
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class NotificationController extends Controller
{
public function index(Request $request): View
{
$notifications = $request->user()
->notifications()
->latest()
->paginate(20);
return view('notifications.index', compact('notifications'));
}
public function unread(Request $request): JsonResponse
{
$notifications = $request->user()
->unreadNotifications()
->latest()
->take(10)
->get()
->map(fn ($n) => [
'id' => $n->id,
'type' => class_basename($n->type),
'title' => $n->data['title'] ?? 'Notification',
'message' => $n->data['message'] ?? '',
'icon' => $n->data['icon'] ?? 'bell',
'url' => $n->data['url'] ?? null,
'created_at' => $n->created_at->diffForHumans(),
]);
return response()->json([
'notifications' => $notifications,
'unread_count' => $request->user()->unreadNotifications()->count(),
]);
}
public function markAsRead(Request $request, string $id): JsonResponse
{
$notification = $request->user()
->notifications()
->where('id', $id)
->first();
if ($notification) {
$notification->markAsRead();
}
return response()->json(['success' => true]);
}
public function markAllAsRead(Request $request): JsonResponse
{
$request->user()->unreadNotifications->markAsRead();
return response()->json(['success' => true]);
}
}
@@ -0,0 +1,86 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
use App\Services\Events\EventRegistrationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class EventRegistrationController extends Controller
{
public function __construct(private EventRegistrationService $eventService) {}
public function register(Request $request, string $shortCode): JsonResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_EVENT, 404);
$request->validate([
'tier' => ['required', 'string', 'max:80'],
'amount' => ['nullable', 'numeric', 'min:1', 'max:1000000'],
'attendee_name' => ['required', 'string', 'max:120'],
'attendee_email' => ['required', 'email', 'max:200'],
'attendee_phone' => ['nullable', 'string', 'max:30'],
'badge_fields' => ['nullable', 'array'],
]);
try {
$result = $this->eventService->register($qrCode, $request->all());
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'paid' => $result['paid'],
'checkout_url' => $result['checkout_url'],
'badge_code' => $result['registration']->badge_code,
'success_url' => route('qr.public.event.confirmed', [
'shortCode' => $qrCode->short_code,
'ref' => $result['registration']->reference,
]),
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse|View
{
$reference = (string) $request->query('reference', '');
if ($reference === '') {
return redirect('/q/' . $shortCode)->with('error', 'Missing payment reference.');
}
try {
$registration = $this->eventService->complete($reference);
return redirect()->route('qr.public.event.confirmed', [
'shortCode' => $shortCode,
'ref' => $registration->reference,
]);
} catch (\Throwable $e) {
return redirect('/q/' . $shortCode)->with('order_error', 'Payment could not be verified. Reference: ' . $reference);
}
}
public function confirmed(string $shortCode, string $ref): View
{
$registration = QrEventRegistration::query()
->where('reference', $ref)
->whereHas('qrCode', fn ($q) => $q->where('short_code', $shortCode))
->firstOrFail();
return view('public.qr.event-confirmed', [
'registration' => $registration,
'qrCode' => $registration->qrCode,
]);
}
}
@@ -0,0 +1,336 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrScanRecorder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrScanController extends Controller
{
public function __construct(
private QrScanRecorder $scanRecorder,
) {}
public function resolve(Request $request, string $shortCode): RedirectResponse|View
{
$qrCode = QrCode::query()->where('short_code', $shortCode)->firstOrFail();
$this->scanRecorder->record($qrCode, $request);
if (! $qrCode->is_active) {
return view('public.qr.inactive', ['qrCode' => $qrCode]);
}
if ($qrCode->resolvesToRedirect()) {
$url = $qrCode->redirectUrl();
abort_unless($url, 404);
return redirect()->away($url);
}
if ($qrCode->isDocumentType()) {
return redirect()->route('qr.public.view', $shortCode);
}
if ($qrCode->isBookType()) {
return view('public.qr.book-landing', ['qrCode' => $qrCode]);
}
if ($qrCode->usesLandingPage()) {
return view('public.qr.landing', ['qrCode' => $qrCode]);
}
abort(404);
}
public function view(string $shortCode): View
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
abort_unless($qrCode->isDocumentType() && $qrCode->document, 404);
return view('public.qr.document-viewer', [
'qrCode' => $qrCode,
'fileUrl' => route('qr.public.file', $shortCode),
'allowDownload' => (bool) ($qrCode->content()['allow_download'] ?? true),
]);
}
public function file(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->with('document')
->firstOrFail();
$document = $qrCode->document;
abort_unless($document && Storage::disk($document->disk)->exists($document->path), 404);
return Storage::disk($document->disk)->response(
$document->path,
($document->title ?: 'document') . '.pdf',
['Content-Type' => 'application/pdf'],
);
}
public function image(string $shortCode, int $index): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isImageType(), 404);
$images = $qrCode->content()['images'] ?? [];
abort_unless(isset($images[$index]['path']), 404);
$path = $images[$index]['path'];
abort_unless(Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function itemImage(string $shortCode, int $sectionIndex, int $itemIndex): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$sections = $qrCode->content()['sections'] ?? [];
$path = $sections[$sectionIndex]['items'][$itemIndex]['image_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function vcard(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$c = $qrCode->content();
$lines = [
'BEGIN:VCARD',
'VERSION:3.0',
'N:' . ($c['last_name'] ?? '') . ';' . ($c['first_name'] ?? '') . ';;;',
'FN:' . trim(($c['first_name'] ?? '') . ' ' . ($c['last_name'] ?? '')),
];
if (! empty($c['company'])) {
$lines[] = 'ORG:' . $this->escapeVcard($c['company']);
}
if (! empty($c['phone'])) {
$lines[] = 'TEL;TYPE=CELL:' . $this->escapeVcard($c['phone']);
}
if (! empty($c['email'])) {
$lines[] = 'EMAIL;TYPE=INTERNET:' . $this->escapeVcard($c['email']);
}
if (! empty($c['website'])) {
$lines[] = 'URL:' . $this->escapeVcard($c['website']);
}
if (! empty($c['address'])) {
$lines[] = 'ADR;TYPE=WORK:;;' . $this->escapeVcard($c['address']) . ';;;;';
}
if (! empty($c['note'])) {
$lines[] = 'NOTE:' . $this->escapeVcard($c['note']);
}
$lines[] = 'END:VCARD';
$vcf = implode("\r\n", $lines) . "\r\n";
$filename = Str::slug($qrCode->label ?: 'contact') . '.vcf';
return response()->streamDownload(fn () => print($vcf), $filename, [
'Content-Type' => 'text/vcard',
]);
}
public function vcardAvatar(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_VCARD, 404);
$path = $qrCode->content()['avatar_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function bookCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isBookType(), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function menuCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless(in_array($qrCode->type, [QrCode::TYPE_MENU, QrCode::TYPE_SHOP], true), 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function businessCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_BUSINESS, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function churchLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
$path = $qrCode->content()['logo_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function churchCover(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_CHURCH, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function eventLogo(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'logo_path');
}
public function eventCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_EVENT, 'cover_path');
}
public function itineraryCover(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_ITINERARY, 'cover_path');
}
private function serveContentImage(string $shortCode, string $type, string $key): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === $type, 404);
$path = $qrCode->content()[$key] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function appIcon(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_APP, 404);
$path = $qrCode->content()['icon_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
private function escapeVcard(string $value): string
{
return str_replace(["\n", "\r", ',', ';'], [' ', ' ', '\\,', '\\;'], $value);
}
}
@@ -0,0 +1,121 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class AccountController extends Controller
{
public function __construct(private BillingClient $billing) {}
private function topupUrl(): string
{
return 'https://'.config('app.account_domain').'/wallet';
}
/** @return array{0: int, 1: array<string, mixed>} */
private function billingSnapshot(?string $publicId): array
{
if (! $publicId) {
return [0, []];
}
$balanceMinor = $this->billing->balanceMinor($publicId);
$ledger = $this->billing->serviceLedger($publicId, 'qr');
return [$balanceMinor, $ledger];
}
public function wallet(): View
{
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.wallet', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function billing(): View
{
$user = ladill_account();
[$balanceMinor, $ledger] = $this->billingSnapshot($user?->public_id);
return view('qr.account.billing', [
'balanceMinor' => $balanceMinor,
'spentMinor' => (int) ($ledger['spent_minor'] ?? 0),
'creditedMinor' => (int) ($ledger['credited_minor'] ?? 0),
'topupUrl' => $this->topupUrl(),
]);
}
public function settings(): View
{
$account = ladill_account();
$settings = $account->getOrCreateQrSetting();
$style = $settings->resolvedDefaultStyle();
return view('qr.account.settings', [
'account' => $account,
'settings' => $settings,
'style' => $style,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
'low_balance_alerts' => ['nullable', 'boolean'],
'default_type' => ['nullable', 'in:'.implode(',', QrTypeCatalog::eventsTypes())],
'default_style' => ['nullable', 'array'],
'default_style.foreground' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.background' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.error_correction' => ['nullable', 'in:L,M,Q,H'],
'default_style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
'default_style.module_style' => ['nullable', 'in:'.implode(',', QrModuleStyleCatalog::keys())],
'default_style.finder_outer' => ['nullable', 'string', 'max:32'],
'default_style.finder_inner' => ['nullable', 'string', 'max:32'],
'default_style.frame_style' => ['nullable', 'string', 'max:32'],
'default_style.frame_text' => ['nullable', 'string', 'max:100'],
'default_style.frame_color' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_type' => ['nullable', 'in:none,linear,radial'],
'default_style.gradient_color1' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_color2' => ['nullable', 'regex:/^#[0-9a-fA-F]{6}$/'],
'default_style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
]);
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
'low_balance_alerts' => $request->boolean('low_balance_alerts'),
'default_type' => $data['default_type'] ?? null,
'default_style' => QrSetting::sanitizeDefaultStyle($data['default_style'] ?? null),
],
);
return redirect()->route('account.settings')->with('success', 'Settings saved.');
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AfiaController extends Controller
{
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context());
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string,mixed> */
private function context(): array
{
$account = ladill_account();
if (! $account) {
return ['signed_in' => 'no'];
}
$types = QrTypeCatalog::eventTypes();
$codes = $account->qrCodes()->whereIn('type', $types);
$ctx = [
'signed_in' => 'yes',
'qr_codes_total' => (clone $codes)->count(),
'qr_codes_active' => (clone $codes)->where('is_active', true)->count(),
'scans_total' => (int) (clone $codes)->sum('scans_total'),
'top_code_type' => (clone $codes)
->selectRaw('type, count(*) as total')
->groupBy('type')
->orderByDesc('total')
->value('type') ?: 'none yet',
];
if ($account->public_id) {
try {
$ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2);
} catch (\Throwable) {
}
}
$recent = $account->qrCodes()
->whereIn('type', $types)
->latest('updated_at')
->limit(3)
->get(['type', 'label', 'short_code', 'scans_total']);
if ($recent->isNotEmpty()) {
$ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf(
'%s (%s, %d scans)',
$code->label ?: $code->short_code,
QrTypeCatalog::label($code->type),
$code->scans_total,
))->implode('; ');
}
return $ctx;
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
/**
* Developers personal API tokens for the Ladill QR Plus API (Sanctum). Tokens
* belong to the signed-in user and authorize calls to /api/v1/*. The plaintext
* token is shown once on create.
*/
class DeveloperController extends Controller
{
public function index(Request $request): View
{
return view('qr.account.developers', [
'tokens' => $request->user()->tokens()->latest()->get(),
'apiBase' => rtrim((string) config('app.url'), '/').'/api/v1',
'newToken' => session('new_token'),
]);
}
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
'name' => ['required', 'string', 'max:60'],
]);
$token = $request->user()->createToken($data['name'], ['qr:read', 'qr:write']);
return redirect()->route('account.developers')
->with('new_token', $token->plainTextToken)
->with('success', 'Token created — copy it now, it wont be shown again.');
}
public function destroy(Request $request, int $token): RedirectResponse
{
$request->user()->tokens()->whereKey($token)->delete();
return redirect()->route('account.developers')->with('success', 'Token revoked.');
}
}
@@ -0,0 +1,450 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrWallet;
use App\Services\Billing\BillingClient;
use App\Services\Qr\QrAnalyticsService;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use App\Services\Qr\QrWalletBillingService;
use App\Support\Qr\QrCornerStyleCatalog;
use App\Support\Qr\QrFrameStyleCatalog;
use App\Support\Qr\QrModuleStyleCatalog;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
use RuntimeException;
use Throwable;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class QrCodeController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrWalletBillingService $billing,
private QrAnalyticsService $analytics,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
private BillingClient $platformBilling,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrCodes = $account->qrCodes()
->whereIn('type', QrTypeCatalog::eventTypes())
->latest()
->get();
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('Events index could not load Ladill wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('qr-codes.index', [
'wallet' => $wallet,
'qrCodes' => $qrCodes,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function create(Request $request): View
{
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$qrSettings = $account->getOrCreateQrSetting();
$requestedType = $request->query('type', QrCode::TYPE_EVENT);
if (! QrTypeCatalog::isValid($requestedType)) {
$requestedType = QrCode::TYPE_EVENT;
}
return view('qr-codes.create', [
'wallet' => $wallet,
'types' => collect(QrTypeCatalog::all())->only([$requestedType])->all(),
'requestedType' => $requestedType,
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $this->platformBilling->balanceMinor($account->public_id) / 100,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
'defaultType' => $qrSettings->resolvedDefaultType(),
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
]);
}
public function checkSlug(Request $request): JsonResponse
{
$code = (string) $request->query('code', '');
if (! preg_match('/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', $code)) {
return response()->json(['available' => false, 'reason' => 'invalid']);
}
$taken = QrCode::where('short_code', $code)->exists();
return response()->json(['available' => ! $taken]);
}
public function store(Request $request): RedirectResponse
{
$validated = $request->validate([
'label' => ['required', 'string', 'max:120'],
'type' => ['required', 'in:' . implode(',', QrTypeCatalog::keys())],
'custom_short_code' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9-]{1,18}[a-z0-9]$/', 'unique:qr_codes,short_code'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
...$this->styleRules(),
]);
if ($validated['type'] === QrCode::TYPE_DOCUMENT && ! $request->hasFile('document')) {
return back()->withInput()->with('error', 'Upload a PDF for PDF QR codes.');
}
if ($validated['type'] === QrCode::TYPE_IMAGE && ! $request->hasFile('image') && ! $request->hasFile('images')) {
return back()->withInput()->with('error', 'Upload at least one image.');
}
try {
$qrCode = $this->manager->create(ladill_account(), array_merge(
$request->all(),
[
'style' => $validated['style'] ?? [],
'custom_short_code' => $validated['custom_short_code'] ?? null,
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
if (str_contains($e->getMessage(), 'Insufficient') || str_contains($e->getMessage(), 'Add at least')) {
return back()->withInput()->with('open_topup_modal', 'qr');
}
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('events.show', $qrCode)
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
}
public function show(Request $request, QrCode $event): View
{
$this->authorize('view', $event);
$previewDataUri = $this->imageGenerator->previewDataUri($event);
$qrCode = $event->fresh();
$qrStyle = $qrCode->style();
$logoDataUri = ! empty($qrStyle['logo_path'])
? $this->imageGenerator->logoDataUri($qrStyle['logo_path'])
: null;
$account = ladill_account();
$wallet = $this->manager->walletFor($account);
$summary = $this->analytics->summaryFor($qrCode);
$dailyScans = $this->analytics->dailyScans($qrCode, 30);
$devices = $this->analytics->breakdown($qrCode, 'device_type');
$browsers = $this->analytics->breakdown($qrCode, 'browser');
$recentScans = $this->analytics->recentScans($qrCode);
$ladillWalletBalance = 0.0;
try {
$ladillWalletBalance = $this->platformBilling->balanceMinor($account->public_id) / 100;
} catch (Throwable $e) {
Log::warning('QR Plus show could not load Ladill wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('qr-codes.show', [
'qrCode' => $qrCode,
'previewDataUri' => $previewDataUri,
'logoDataUri' => $logoDataUri,
'types' => QrTypeCatalog::all(),
'moduleStyles' => QrModuleStyleCatalog::visible(),
'cornerOuterStyles' => QrCornerStyleCatalog::outerStyles(),
'cornerInnerStyles' => QrCornerStyleCatalog::innerStyles(),
'frameStyles' => QrFrameStyleCatalog::visible(),
'wallet' => $wallet,
'summary' => $summary,
'dailyScans' => $dailyScans,
'devices' => $devices,
'browsers' => $browsers,
'recentScans' => $recentScans,
'orders' => null,
'pricePerQr' => QrWallet::pricePerQr(),
'minTopup' => QrWallet::minTopupGhs(),
'ladillWalletBalance' => $ladillWalletBalance,
'topupUrl' => 'https://'.config('app.account_domain').'/wallet',
]);
}
public function update(Request $request, QrCode $event): RedirectResponse
{
$this->authorize('update', $event);
$qrCode = $event;
$validated = $request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'destination_url' => ['nullable', 'url', 'max:2048'],
'document' => ['nullable', 'file', 'mimes:pdf', 'max:102400'],
'image' => ['nullable', 'image', 'max:10240'],
'images' => ['nullable', 'array'],
'images.*' => ['image', 'max:10240'],
'item_images' => ['nullable', 'array'],
'item_images.*' => ['array'],
'item_images.*.*' => ['image', 'max:4096'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'menu_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'menu_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'business_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'business_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'event_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'event_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'itinerary_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
'remove_logo' => ['sometimes', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
...$this->styleRules(),
]);
try {
$this->manager->update($qrCode, array_merge(
$request->all(),
[
'document' => $request->file('document'),
'logo' => $request->file('logo'),
'style' => $validated['style'] ?? null,
'is_active' => $request->boolean('is_active', $qrCode->is_active),
'remove_logo' => $request->boolean('remove_logo'),
'avatar' => $request->file('avatar'),
'book_file' => $request->file('book_file'),
'cover' => $request->file('cover'),
'menu_logo' => $request->file('menu_logo'),
'menu_cover' => $request->file('menu_cover'),
'business_logo' => $request->file('business_logo'),
'business_cover' => $request->file('business_cover'),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
'event_logo' => $request->file('event_logo'),
'event_cover' => $request->file('event_cover'),
'itinerary_cover' => $request->file('itinerary_cover'),
],
));
} catch (RuntimeException $e) {
return back()->with('error', $e->getMessage());
}
return back()->with('success', QrTypeCatalog::label($qrCode->type).' updated.');
}
public function stylePreview(Request $request): Response
{
$validated = $request->validate(array_merge($this->styleRules(), [
'short_code' => ['nullable', 'string', 'max:16'],
'logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:2048'],
'use_existing_logo' => ['nullable', 'boolean'],
'existing_logo_path' => ['nullable', 'string', 'max:500'],
]));
$shortCode = $validated['short_code'] ?? 'preview';
$style = $validated['style'] ?? [];
$tempLogoPath = null;
if ($request->hasFile('logo')) {
$tempLogoPath = $request->file('logo')->store('tmp/previews', 'qr');
$style['logo_path'] = $tempLogoPath;
} elseif ($request->boolean('use_existing_logo') && ! empty($validated['existing_logo_path'])) {
$existingPath = $validated['existing_logo_path'];
$userPrefix = ladill_account()->id . '/';
if (str_starts_with($existingPath, $userPrefix) && Storage::disk('qr')->exists($existingPath)) {
$style['logo_path'] = $existingPath;
}
}
$png = $this->imageGenerator->renderPng(QrCode::publicBaseUrl() . '/q/' . $shortCode, $style);
if ($tempLogoPath) {
Storage::disk('qr')->delete($tempLogoPath);
}
return response($png, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'no-store',
]);
}
/** @return array<string, mixed> */
private function styleRules(): array
{
return [
'style' => ['nullable', 'array'],
'style.foreground' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.background' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.error_correction' => ['nullable', 'in:L,M,Q,H'],
'style.margin' => ['nullable', 'integer', 'min:0', 'max:10'],
'style.module_style' => ['nullable', 'in:' . implode(',', QrModuleStyleCatalog::keys())],
'style.finder_outer' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::outerStyles()))],
'style.finder_inner' => ['nullable', 'in:' . implode(',', array_keys(QrCornerStyleCatalog::innerStyles()))],
'style.frame_style' => ['nullable', 'in:' . implode(',', QrFrameStyleCatalog::keys())],
'style.frame_text' => ['nullable', 'string', 'max:100'],
'style.frame_color' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.scale' => ['nullable', 'integer', 'min:4', 'max:16'],
'style.gradient_type' => ['nullable', 'in:none,linear,radial'],
'style.gradient_color1' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_color2' => ['nullable', 'regex:/^#[0-9A-Fa-f]{6}$/'],
'style.gradient_rotation' => ['nullable', 'integer', 'min:0', 'max:360'],
'style.logo_size' => ['nullable', 'numeric', 'min:0.1', 'max:0.4'],
'style.logo_margin' => ['nullable', 'integer', 'min:0', 'max:15'],
'style.logo_white_bg' => ['nullable', 'boolean'],
'style.logo_shape' => ['nullable', 'in:none,rounded,circle'],
];
}
public function preview(QrCode $event): Response
{
$this->authorize('view', $event);
$qrCode = $this->imageGenerator->ensureValidImages($event);
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response($bytes, 200, [
'Content-Type' => 'image/png',
'Cache-Control' => 'private, max-age=3600',
]);
}
public function download(QrCode $event, string $format): StreamedResponse
{
$this->authorize('view', $event);
$qrCode = $this->imageGenerator->ensureValidImages($event);
$path = $format === 'svg' ? $qrCode->svg_path : $qrCode->png_path;
$filename = Str::slug($qrCode->label) . '-qr.' . ($format === 'svg' ? 'svg' : 'png');
if ($format === 'png') {
$bytes = $this->imageGenerator->normalizeStoredPng($path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
return response()->streamDownload(fn () => print($bytes), $filename, [
'Content-Type' => 'image/png',
]);
}
if ($format === 'pdf') {
$bytes = $this->imageGenerator->normalizeStoredPng($qrCode->png_path);
if ($bytes === null) {
$bytes = $this->imageGenerator->renderPng($qrCode->encodedPayload(), $qrCode->style());
$this->imageGenerator->generateAndStore($qrCode);
}
$pdf = $this->pdfExporter->fromPng($bytes, $qrCode->label);
$filename = Str::slug($qrCode->label) . '-qr.pdf';
return response()->streamDownload(fn () => print($pdf), $filename, [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
/**
* Persist the exact client-rendered QR (qr-code-styling) as the canonical
* SVG + PNG so downloads match the preview pixel-for-pixel. The browser
* renders with the real /q/{code} URL on the show page, then posts here.
*/
public function storeCanonicalImage(Request $request, QrCode $event): JsonResponse
{
$this->authorize('update', $event);
$qrCode = $event;
$validated = $request->validate([
'svg' => ['required', 'string', 'max:600000'],
'png' => ['required', 'string', 'max:4000000'],
]);
$svg = $this->imageGenerator->sanitizeSvg($validated['svg']);
if ($svg === null) {
return response()->json(['error' => 'Invalid SVG.'], 422);
}
$pngB64 = $validated['png'];
if (str_contains($pngB64, ',')) {
$pngB64 = substr($pngB64, strpos($pngB64, ',') + 1);
}
$png = base64_decode($pngB64, true);
if ($png === false || ! $this->imageGenerator->isValidPngBinary($png)) {
return response()->json(['error' => 'Invalid PNG.'], 422);
}
$basePath = $qrCode->user_id . '/codes/' . $qrCode->id;
Storage::disk('qr')->put($basePath . '/qr.svg', $svg);
Storage::disk('qr')->put($basePath . '/qr.png', $png);
$qrCode->update([
'svg_path' => $basePath . '/qr.svg',
'png_path' => $basePath . '/qr.png',
]);
return response()->json(['ok' => true]);
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Illuminate\View\View;
class TeamController extends Controller
{
public function index(Request $request): View
{
$account = ladill_account();
$members = QrTeamMember::query()
->where('account_id', $account->id)
->with('member')
->orderBy('status')->orderBy('email')
->get();
return view('qr.account.team', [
'account' => $account,
'members' => $members,
'canManage' => $this->canManage($request),
'isOwner' => $request->user()->id === $account->id,
]);
}
public function store(Request $request): RedirectResponse
{
abort_unless($this->canManage($request), 403);
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'in:admin,member'],
]);
$account = ladill_account();
$email = strtolower($validated['email']);
if ($email === strtolower($account->email)) {
return back()->withErrors(['email' => 'The owner is already on the account.']);
}
$member = QrTeamMember::firstOrNew(['account_id' => $account->id, 'email' => $email]);
$member->role = $validated['role'];
if (! $member->exists) {
$member->status = QrTeamMember::STATUS_INVITED;
$member->token = Str::random(40);
}
$member->save();
if ($existing = User::whereRaw('LOWER(email) = ?', [$email])->first()) {
QrTeamMember::linkPendingInvitesFor($existing);
}
return back()->with('success', $email.' invited.');
}
public function updateRole(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$validated = $request->validate(['role' => ['required', 'in:admin,member']]);
$member->update(['role' => $validated['role']]);
return back()->with('success', 'Role updated.');
}
public function destroy(Request $request, QrTeamMember $member): RedirectResponse
{
abort_unless($this->canManage($request) && $member->account_id === ladill_account()->id, 403);
$member->delete();
return back()->with('success', 'Member removed.');
}
public function switchAccount(Request $request): RedirectResponse
{
$validated = $request->validate(['account' => ['required', 'integer']]);
abort_unless($request->user()->canAccessAccount((int) $validated['account']), 403);
$request->session()->put('ladill_account', (int) $validated['account']);
return redirect()->route('events.dashboard');
}
private function canManage(Request $request): bool
{
$user = $request->user();
$account = ladill_account();
if ($user->id === $account->id) {
return true;
}
return $user->memberships()
->where('account_id', $account->id)
->where('role', QrTeamMember::ROLE_ADMIN)
->exists();
}
}