Extract Ladill Events as a standalone events suite at events.ladill.com.
Deploy Ladill QR Plus / deploy (push) Successful in 28s
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 won’t 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]);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user