Initial Ladill Merchant app with Gitea deploy pipeline.

Shop, menu, and booking storefronts with OIDC SSO, Pay checkout, and merchant.ladill.com deployment workflow.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-09 23:05:21 +00:00
co-authored by Cursor
commit f718b9cfbf
311 changed files with 38972 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,314 @@
<?php
namespace App\Http\Controllers\Auth;
use App\Http\Controllers\Controller;
use App\Models\QrTeamMember;
use App\Models\User;
use Illuminate\Http\Client\Response as HttpResponse;
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\Facades\Route;
use Illuminate\Support\Str;
use Illuminate\View\View;
/**
* "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|View
{
$intended = (string) $request->query('redirect', route('merchant.dashboard'));
if (Auth::check()) {
return $this->safeRedirect($intended, route('merchant.dashboard'));
}
if ($this->attemptSilentRefresh($request, $intended)) {
return $this->safeRedirect($intended, route('merchant.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',
];
$loginHint = (string) $request->session()->get('sso.login_hint', '');
if ($loginHint !== '') {
$query['login_hint'] = $loginHint;
}
if (! $request->boolean('interactive')) {
$query['prompt'] = 'none';
}
$authorizeUrl = rtrim((string) config('services.ladill_sso.issuer'), '/').'/oauth/authorize?'.http_build_query($query);
if ($request->boolean('interactive') && ! $request->boolean('fallback')) {
$request->session()->put('sso.popup', true);
return view('auth.sso-signing-in', [
'authorizeUrl' => $authorizeUrl,
'intended' => $intended,
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
return redirect()->away($authorizeUrl);
}
public function callback(Request $request): RedirectResponse|View
{
$intended = (string) $request->session()->get('sso.intended', route('merchant.dashboard'));
$popup = (bool) $request->session()->get('sso.popup');
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 $this->finishCallback($request, $intended, (string) $request->query('error_description', $request->query('error')), $popup);
}
if (! $request->filled('code')
|| $request->query('state') !== $request->session()->pull('sso.state')) {
return $this->finishCallback($request, $intended, 'invalid_state', $popup);
}
$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 $this->finishCallback($request, $intended, 'token_exchange_failed', $popup);
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return $this->finishCallback($request, $intended, 'userinfo_failed', $popup);
}
QrTeamMember::linkPendingInvitesFor($user);
Auth::login($user, remember: true);
$request->session()->regenerate();
return $this->finishCallback($request, $intended, null, $popup);
}
public function logout(Request $request): RedirectResponse
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('sso.logout-bridge', [
'return' => $this->defaultSignedOutUrl(),
]);
}
public function logoutBridge(Request $request): View
{
$return = $this->safeReturnUrl((string) $request->query('return', ''));
$hubUrl = 'https://'.config('app.auth_domain').'/logout/sso/hub?'.http_build_query([
'embedded' => 1,
'return' => $return,
]);
return view('auth.sso-logout-bridge', [
'hubUrl' => $hubUrl,
'return' => $return,
'authOrigin' => 'https://'.config('app.auth_domain'),
]);
}
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 attemptSilentRefresh(Request $request, string $intended): bool
{
$refreshToken = (string) $request->session()->get('sso.refresh_token', '');
if ($refreshToken === '') {
return false;
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$tokenRes = Http::asForm()->post($issuer.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $refreshToken,
'client_id' => (string) config('services.ladill_sso.client_id'),
'client_secret' => (string) config('services.ladill_sso.client_secret'),
'scope' => 'openid profile email',
]);
if ($tokenRes->failed()) {
$request->session()->forget('sso.refresh_token');
return false;
}
$user = $this->loginFromTokenResponse($request, $tokenRes);
if (! $user) {
return false;
}
Auth::login($user, remember: true);
$request->session()->put('sso.intended', $intended);
return true;
}
private function loginFromTokenResponse(Request $request, HttpResponse $tokenRes): ?User
{
$refreshToken = (string) $tokenRes->json('refresh_token', '');
if ($refreshToken !== '') {
$request->session()->put('sso.refresh_token', $refreshToken);
}
$issuer = rtrim((string) config('services.ladill_sso.issuer'), '/');
$claims = Http::withToken((string) $tokenRes->json('access_token'))->acceptJson()->get($issuer.'/oauth/userinfo');
if ($claims->failed() || ! $claims->json('sub')) {
return null;
}
$email = (string) ($claims->json('email') ?: '');
if ($email !== '') {
$request->session()->put('sso.login_hint', $email);
}
return User::updateOrCreate(
['public_id' => (string) $claims->json('sub')],
[
'name' => $claims->json('name'),
'email' => $email !== '' ? $email : (string) $claims->json('sub').'@users.ladill.com',
'avatar_url' => $claims->json('picture'),
],
);
}
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 finishCallback(Request $request, string $intended, ?string $error = null, bool $popup = false): RedirectResponse|View
{
if ($popup) {
return view('auth.sso-popup-done', [
'intended' => $intended,
'error' => $error,
'appOrigin' => rtrim((string) config('app.url'), '/'),
'fallbackUrl' => route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
'fallback' => 1,
]),
]);
}
if ($error) {
return redirect()->route('sso.connect', [
'redirect' => $intended,
'interactive' => 1,
]);
}
return $this->safeRedirect($intended, route('merchant.dashboard'));
}
private function defaultSignedOutUrl(): string
{
foreach (array_keys(Route::getRoutes()->getRoutesByName()) as $name) {
if (str_ends_with($name, '.signed-out')) {
return route($name);
}
}
return 'https://'.config('app.platform_domain');
}
private function safeReturnUrl(string $url): string
{
$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 $url;
}
return $this->defaultSignedOutUrl();
}
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,100 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrBooking;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
use App\Services\Merchant\MerchantSaleService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Illuminate\View\View;
class OrdersController extends Controller
{
public function __construct(private MerchantSaleService $sales) {}
public function index(Request $request): View
{
$account = ladill_account();
$search = trim((string) $request->query('q', ''));
$qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$orders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('created_at')
->paginate(15, ['*'], 'orders_page')
->withQueryString();
$bookings = QrBooking::query()
->whereIn('qr_code_id', $qrIds)
->whereIn('status', [QrBooking::STATUS_CONFIRMED, QrBooking::STATUS_PENDING])
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('customer_name', 'like', $like)
->orWhere('customer_email', 'like', $like)
->orWhere('service_name', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode')
->latest('starts_at')
->paginate(15, ['*'], 'bookings_page')
->withQueryString();
return view('merchant.orders', [
'orders' => $orders,
'bookings' => $bookings,
'search' => $search,
]);
}
public function updateStatus(Request $request, QrSaleOrder $order): RedirectResponse
{
abort_if($order->user_id !== ladill_account()->id, 403);
abort_if($order->status !== QrSaleOrder::STATUS_PAID, 422, 'Only paid orders can be updated.');
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrSaleOrder::FULFILLMENT_STATUSES))],
]);
$order->update(['fulfillment_status' => $validated['fulfillment_status']]);
$order->load('qrCode');
$this->sales->notifyStatusUpdated($order);
return back()->with('success', 'Order status updated.');
}
public function updateBookingStatus(Request $request, QrBooking $booking): RedirectResponse
{
abort_if($booking->user_id !== ladill_account()->id, 403);
abort_if($booking->status !== QrBooking::STATUS_CONFIRMED, 422);
$validated = $request->validate([
'fulfillment_status' => ['required', Rule::in(array_keys(QrBooking::FULFILLMENT_STATUSES))],
]);
$booking->update(['fulfillment_status' => $validated['fulfillment_status']]);
$booking->load('qrCode');
$this->sales->notifyBookingStatusUpdated($booking);
return back()->with('success', 'Booking status updated.');
}
}
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrSaleOrder;
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();
$qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::storefrontTypes())
->pluck('id');
$todayStart = now()->startOfDay();
$todayOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', $todayStart);
$todayCount = (clone $todayOrders)->count();
$todayRevenue = (float) (clone $todayOrders)->sum('merchant_amount_ghs');
$recentOrders = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->with('qrCode')
->latest('paid_at')
->limit(8)
->get();
$storefrontCount = $qrIds->count();
$orders30d = QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->where('paid_at', '>=', now()->subDays(30))
->count();
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Merchant dashboard could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
return view('merchant.dashboard', [
'todayCount' => $todayCount,
'todayRevenue' => $todayRevenue,
'storefrontCount' => $storefrontCount,
'orders30d' => $orders30d,
'recentOrders' => $recentOrders,
'balanceMinor' => $balanceMinor,
]);
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrSaleOrder;
use App\Models\QrCode;
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();
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_SHOP)
->pluck('id');
$revenueMinor = (int) QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->where('status', QrSaleOrder::STATUS_PAID)
->sum('merchant_amount_minor');
$balanceMinor = 0;
try {
$balanceMinor = $this->billing->balanceMinor($account->public_id);
} catch (Throwable $e) {
Log::warning('Give payouts could not load wallet balance', [
'user' => $account->public_id,
'error' => $e->getMessage(),
]);
}
$accountWalletUrl = 'https://'.config('app.account_domain').'/wallet';
return view('merchant.payouts', [
'revenueMinor' => $revenueMinor,
'balanceMinor' => $balanceMinor,
'accountWalletUrl' => $accountWalletUrl,
]);
}
}
@@ -0,0 +1,211 @@
<?php
namespace App\Http\Controllers\Merchant;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Qr\QrCodeManagerService;
use App\Services\Qr\QrImageGeneratorService;
use App\Services\Qr\QrPdfExporter;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\View\View;
use RuntimeException;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\StreamedResponse;
class StorefrontController extends Controller
{
public function __construct(
private QrCodeManagerService $manager,
private QrImageGeneratorService $imageGenerator,
private QrPdfExporter $pdfExporter,
) {}
public function index(Request $request): View
{
$account = ladill_account();
$qrCodes = $account->qrCodes()
->whereIn('type', \App\Support\Qr\QrTypeCatalog::storefrontTypes())
->latest()
->get();
$previewDataUris = $qrCodes->mapWithKeys(function (QrCode $qr) {
return [$qr->id => $this->imageGenerator->previewDataUri($qr)];
});
return view('merchant.storefronts.index', [
'qrCodes' => $qrCodes,
'previewDataUris' => $previewDataUris,
]);
}
public function create(): View
{
return view('merchant.storefronts.create');
}
public function store(Request $request): RedirectResponse
{
$account = ladill_account();
$request->validate([
'label' => ['required', 'string', 'max:120'],
'name' => ['required', 'string', 'max:120'],
'org_type' => ['required', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'type' => QrCode::TYPE_SHOP,
'currency' => 'GHS',
'accepts_payment' => true,
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$qrCode = $this->manager->create($account, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return redirect()->route('merchant.storefronts.show', $qrCode)->with('success', 'Storefront created.');
}
public function show(QrCode $storefront): View
{
$this->authorizeStorefront($storefront);
return view('merchant.storefronts.show', [
'qrCode' => $storefront->fresh(),
'previewDataUri' => $this->imageGenerator->previewDataUri($storefront),
]);
}
public function update(Request $request, QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$request->validate([
'label' => ['sometimes', 'string', 'max:120'],
'name' => ['sometimes', 'string', 'max:120'],
'org_type' => ['sometimes', 'in:church,school,mosque,ngo,club'],
'denomination' => ['nullable', 'string', 'max:120'],
'description' => ['nullable', 'string', 'max:2000'],
'phone' => ['nullable', 'string', 'max:30'],
'email' => ['nullable', 'email', 'max:200'],
'website' => ['nullable', 'url', 'max:2048'],
'address' => ['nullable', 'string', 'max:500'],
'service_times' => ['nullable', 'string', 'max:200'],
'brand_color' => ['nullable', 'string', 'max:20'],
'collection_types' => ['nullable', 'array', 'min:1'],
'collection_types.*' => ['string', 'max:80'],
'is_active' => ['sometimes', 'boolean'],
'church_logo' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:4096'],
'church_cover' => ['nullable', 'image', 'mimes:jpeg,jpg,png,gif,webp', 'max:8192'],
]);
$data = array_merge($request->all(), [
'accepts_payment' => true,
'is_active' => $request->boolean('is_active', $storefront->is_active),
'church_logo' => $request->file('church_logo'),
'church_cover' => $request->file('church_cover'),
]);
try {
$this->manager->update($storefront, $data);
} catch (RuntimeException $e) {
return back()->withInput()->with('error', $e->getMessage());
}
return back()->with('success', 'Storefront updated.');
}
public function destroy(QrCode $storefront): RedirectResponse
{
$this->authorizeStorefront($storefront);
$this->manager->delete($storefront);
return redirect()
->route('merchant.storefronts.index')
->with('success', 'Storefront deleted.');
}
public function preview(QrCode $storefront): Response
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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 $storefront, string $format): StreamedResponse
{
$this->authorizeStorefront($storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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);
return response()->streamDownload(fn () => print($pdf), Str::slug($qrCode->label).'-qr.pdf', [
'Content-Type' => 'application/pdf',
]);
}
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->download($path, $filename);
}
private function authorizeStorefront(QrCode $storefront): void
{
abort_unless(in_array($storefront->type, \App\Support\Qr\QrTypeCatalog::storefrontTypes(), true), 404);
abort_unless($storefront->user_id === ladill_account()->id, 403);
}
}
@@ -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,114 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Merchant\BookingSlotService;
use App\Services\Merchant\MerchantSaleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class BookingController extends Controller
{
public function __construct(
private BookingSlotService $slots,
private MerchantSaleService $sales,
) {}
public function slots(Request $request, string $shortCode): JsonResponse
{
$qrCode = $this->resolveBookingQr($shortCode);
$validated = $request->validate([
'date' => ['required', 'date', 'after_or_equal:today'],
'service_index' => ['required', 'integer', 'min:0'],
]);
return response()->json([
'slots' => $this->slots->availableSlots(
$qrCode,
$validated['date'],
(int) $validated['service_index'],
),
]);
}
public function store(Request $request, string $shortCode): JsonResponse
{
$qrCode = $this->resolveBookingQr($shortCode);
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:120'],
'customer_email' => ['required', 'email', 'max:200'],
'customer_phone' => ['nullable', 'string', 'max:30'],
'service_index' => ['required', 'integer', 'min:0'],
'starts_at' => ['required', 'date'],
]);
try {
$result = $this->sales->initiateBooking($qrCode, $validated);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
if ($result['checkout_url']) {
return response()->json([
'checkout_url' => $result['checkout_url'],
'callback_url' => $result['callback_url'],
]);
}
return response()->json([
'confirmed' => true,
'redirect_url' => route('qr.public.booking.confirmed', [
'shortCode' => $shortCode,
'booking' => $result['booking']->id,
]),
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse
{
$reference = trim((string) $request->query('reference', ''));
if ($reference === '') {
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
}
try {
$booking = $this->sales->completeBooking($reference);
} catch (\Throwable) {
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Reference: '.$reference);
}
return redirect()->route('qr.public.booking.confirmed', [
'shortCode' => $shortCode,
'booking' => $booking->id,
]);
}
public function confirmed(string $shortCode, int $booking): View
{
$qrCode = $this->resolveBookingQr($shortCode);
$record = $qrCode->bookings()->whereKey($booking)->firstOrFail();
return view('public.qr.booking-confirmed', [
'qrCode' => $qrCode,
'booking' => $record,
]);
}
private function resolveBookingQr(string $shortCode): QrCode
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->isBookingType(), 404);
return $qrCode;
}
}
@@ -0,0 +1,341 @@
<?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->isBookingType()) {
return view('public.qr.booking-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->isBookingType(), 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 menuLogo(string $shortCode): StreamedResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->where('is_active', true)
->firstOrFail();
abort_unless($qrCode->type === QrCode::TYPE_SHOP, 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($qrCode->type === QrCode::TYPE_SHOP, 404);
$path = $qrCode->content()['cover_path'] ?? null;
abort_unless($path && Storage::disk('qr')->exists($path), 404);
return Storage::disk('qr')->response($path);
}
public function paymentLogo(string $shortCode): StreamedResponse
{
return $this->serveContentImage($shortCode, QrCode::TYPE_PAYMENT, 'logo_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,76 @@
<?php
namespace App\Http\Controllers\Public;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Merchant\MerchantSaleService;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
use RuntimeException;
class SaleOrderController extends Controller
{
public function __construct(private MerchantSaleService $sales) {}
public function store(Request $request, string $shortCode): JsonResponse
{
$qrCode = QrCode::query()
->where('short_code', $shortCode)
->whereIn('type', QrTypeCatalog::storefrontTypes())
->where('is_active', true)
->firstOrFail();
if (! $qrCode->acceptsOrders()) {
return response()->json(['error' => 'This QR code does not accept orders.'], 422);
}
if (empty($qrCode->content()['accepts_payment'])) {
return response()->json(['error' => 'Online payments are not enabled for this storefront.'], 422);
}
$validated = $request->validate([
'customer_name' => ['required', 'string', 'max:120'],
'customer_email' => ['required', 'email', 'max:200'],
'customer_phone' => ['required', 'string', 'max:30'],
'shipping_fee' => ['nullable', 'numeric', 'min:0', 'max:10000'],
'items' => ['required', 'array', 'min:1'],
'items.*.name' => ['required', 'string', 'max:200'],
'items.*.price' => ['required', 'numeric', 'min:0'],
'items.*.qty' => ['required', 'integer', 'min:1', 'max:100'],
]);
try {
$result = $this->sales->initiateOrder($qrCode, $validated);
} catch (RuntimeException $e) {
return response()->json(['error' => $e->getMessage()], 422);
}
return response()->json([
'checkout_url' => $result['checkout_url'],
'callback_url' => $result['callback_url'],
]);
}
public function callback(Request $request, string $shortCode): RedirectResponse|View
{
$reference = trim((string) $request->query('reference', ''));
if ($reference === '') {
return redirect('/q/'.$shortCode)->with('error', 'Missing payment reference.');
}
try {
$order = $this->sales->completeOrder($reference);
} catch (\Throwable) {
return redirect('/q/'.$shortCode)->with('order_error', 'Payment could not be verified. Contact the merchant with reference: '.$reference);
}
return view('public.qr.order-success', [
'order' => $order,
'qrCode' => $order->qrCode,
]);
}
}
@@ -0,0 +1,94 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrSetting;
use App\Services\Billing\BillingClient;
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, config('billing.service', 'mini'));
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();
return view('merchant.settings', [
'account' => $account,
'settings' => $settings,
]);
}
public function updateSettings(Request $request): RedirectResponse
{
$account = ladill_account();
$data = $request->validate([
'notify_email' => ['nullable', 'email', 'max:255'],
'product_updates' => ['nullable', 'boolean'],
'notify_registrations' => ['nullable', 'boolean'],
'notify_payouts' => ['nullable', 'boolean'],
]);
QrSetting::updateOrCreate(
['user_id' => $account->id],
[
'notify_email' => $data['notify_email'] ?? null,
'product_updates' => $request->boolean('product_updates'),
'notify_registrations' => $request->boolean('notify_registrations'),
'notify_payouts' => $request->boolean('notify_payouts'),
],
);
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,453 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Models\QrEventRegistration;
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::storefrontTypes())
->latest()
->get();
$totalRegistrations = 0;
$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,
'totalRegistrations' => $totalRegistrations,
'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_SHOP);
if (! QrTypeCatalog::isValid($requestedType)) {
$requestedType = QrCode::TYPE_SHOP;
}
return view('qr-codes.create', [
'wallet' => $wallet,
'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',
'accountDefaultStyle' => $qrSettings->resolvedDefaultStyle(),
'accountEventDefaults' => $qrSettings->resolvedEventDefaults(),
]);
}
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('merchant.storefronts.show', $qrCode)
->with('success', QrTypeCatalog::label($qrCode->type).' created.');
}
public function show(Request $request, QrCode $storefront): View
{
$this->authorize('view', $storefront);
$previewDataUri = $this->imageGenerator->previewDataUri($storefront);
$qrCode = $storefront->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 $storefront): RedirectResponse
{
$this->authorize('update', $storefront);
$qrCode = $storefront;
$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 $storefront): Response
{
$this->authorize('view', $storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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 $storefront, string $format): StreamedResponse
{
$this->authorize('view', $storefront);
$qrCode = $this->imageGenerator->ensureValidImages($storefront);
$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 $storefront): JsonResponse
{
$this->authorize('update', $storefront);
$qrCode = $storefront;
$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('merchant.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();
}
}
+71
View File
@@ -0,0 +1,71 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrSaleOrder;
use App\Models\QrCode;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\View\View;
class SearchController extends Controller
{
public function __invoke(Request $request): JsonResponse|View
{
$q = trim((string) $request->query('q'));
$results = mb_strlen($q) >= 2 ? $this->results($q) : [];
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
return response()->json(['results' => $results]);
}
return view('search.index', [
'query' => $q,
'results' => $results,
]);
}
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */
private function results(string $q): array
{
$account = ladill_account();
$like = '%'.$q.'%';
$qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_SHOP)
->pluck('id');
if ($qrIds->isEmpty()) {
return [];
}
return QrSaleOrder::query()
->whereIn('qr_code_id', $qrIds)
->with('qrCode')
->where(function ($query) use ($like) {
$query->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('collection_type', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
})
->latest('created_at')
->limit(15)
->get()
->map(function (QrSaleOrder $order): array {
$amount = number_format(
($order->status === QrSaleOrder::STATUS_PAID ? $order->merchant_amount_minor : $order->amount_minor) / 100,
2,
);
return [
'type' => 'order',
'title' => $order->payer_name ?: 'Anonymous donor',
'subtitle' => 'GHS '.$amount.' · '.($order->collection_type ?: 'Order').' · '.ucfirst($order->status),
'url' => route('merchant.orders.index', ['q' => $order->reference]),
];
})
->all();
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Middleware;
use App\Models\User;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\View;
use Symfony\Component\HttpFoundation\Response;
class SetActingAccount
{
public function handle(Request $request, Closure $next): Response
{
if ($user = $request->user()) {
if ($request->is('api/*')) {
$accountId = (int) ($request->header('X-Ladill-Account') ?: $user->id);
} else {
$accountId = (int) $request->session()->get('ladill_account', $user->id);
}
if (! $user->canAccessAccount($accountId)) {
$accountId = $user->id;
if (! $request->is('api/*')) {
$request->session()->put('ladill_account', $accountId);
}
}
$account = $accountId === $user->id ? $user : (User::find($accountId) ?? $user);
$request->attributes->set('actingAccount', $account);
if (! $request->is('api/*')) {
View::share('actingAccount', $account);
View::share('accessibleAccounts', $user->accessibleAccounts());
}
}
return $next($request);
}
}