Add mobile-only payment search for Ladill Mini.
Deploy Ladill Mini / deploy (push) Successful in 24s

Wire the mobile search tab to a payments search page and remove desktop header search shortcuts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-08 21:47:33 +00:00
co-authored by Cursor
parent 4c2ce5526f
commit 7ffa44b128
7 changed files with 80 additions and 42 deletions
@@ -13,6 +13,7 @@ class PaymentsController extends Controller
public function index(Request $request): View public function index(Request $request): View
{ {
$account = ladill_account(); $account = ladill_account();
$search = trim((string) $request->query('q', ''));
$qrIds = $account->qrCodes() $qrIds = $account->qrCodes()
->where('type', QrCode::TYPE_PAYMENT) ->where('type', QrCode::TYPE_PAYMENT)
@@ -20,12 +21,25 @@ class PaymentsController extends Controller
$payments = MiniPayment::query() $payments = MiniPayment::query()
->whereIn('qr_code_id', $qrIds) ->whereIn('qr_code_id', $qrIds)
->when($search !== '', function ($query) use ($search) {
$like = '%'.$search.'%';
$query->where(function ($inner) use ($like) {
$inner->where('payer_name', 'like', $like)
->orWhere('payer_email', 'like', $like)
->orWhere('payer_note', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
});
})
->with('qrCode') ->with('qrCode')
->latest('created_at') ->latest('created_at')
->paginate(25); ->paginate(25)
->withQueryString();
return view('mini.payments', [ return view('mini.payments', [
'payments' => $payments, 'payments' => $payments,
'search' => $search,
]); ]);
} }
} }
+30 -14
View File
@@ -2,8 +2,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\MiniPayment;
use App\Models\QrCode; use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\View\View; use Illuminate\View\View;
@@ -28,26 +28,42 @@ class SearchController extends Controller
/** @return list<array{type: string, title: string, subtitle: string, url: string}> */ /** @return list<array{type: string, title: string, subtitle: string, url: string}> */
private function results(string $q): array private function results(string $q): array
{ {
$account = ladill_account();
$like = '%'.$q.'%'; $like = '%'.$q.'%';
return ladill_account()->qrCodes() $qrIds = $account->qrCodes()
->whereIn('type', QrTypeCatalog::eventsTypes()) ->where('type', QrCode::TYPE_PAYMENT)
->where(function ($query) use ($like): void { ->pluck('id');
$query->where('label', 'like', $like)
->orWhere('short_code', 'like', $like) if ($qrIds->isEmpty()) {
->orWhere('destination_url', 'like', $like); return [];
}
return MiniPayment::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('payer_note', 'like', $like)
->orWhere('reference', 'like', $like)
->orWhere('payment_reference', 'like', $like)
->orWhereHas('qrCode', fn ($qr) => $qr->where('label', 'like', $like));
}) })
->orderByDesc('updated_at') ->latest('created_at')
->limit(15) ->limit(15)
->get() ->get()
->map(function (QrCode $code): array { ->map(function (MiniPayment $payment): array {
$content = $code->content(); $amount = number_format(
($payment->status === MiniPayment::STATUS_PAID ? $payment->merchant_amount_minor : $payment->amount_minor) / 100,
2,
);
return [ return [
'type' => $code->type === QrCode::TYPE_EVENT ? 'event' : 'programme', 'type' => 'payment',
'title' => $content['name'] ?? $code->label, 'title' => $payment->payer_name ?: 'Walk-in customer',
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(), 'subtitle' => 'GHS '.$amount.' · '.($payment->qrCode?->label ?? 'Payment QR').' · '.ucfirst($payment->status),
'url' => route('events.show', $code), 'url' => route('mini.payments.index', ['q' => $payment->reference]),
]; ];
}) })
->all(); ->all();
+2 -11
View File
@@ -65,8 +65,8 @@
@include('partials.mobile-bottom-nav', [ @include('partials.mobile-bottom-nav', [
'homeUrl' => route('mini.dashboard'), 'homeUrl' => route('mini.dashboard'),
'homeActive' => request()->routeIs('mini.dashboard'), 'homeActive' => request()->routeIs('mini.dashboard'),
'searchUrl' => route('mini.payment-qrs.index'), 'searchUrl' => route('mini.search'),
'searchActive' => request()->routeIs('mini.payment-qrs.*'), 'searchActive' => request()->routeIs('mini.search'),
'notificationsUrl' => route('notifications.index'), 'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'), 'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'), 'unreadUrl' => route('notifications.unread'),
@@ -257,15 +257,6 @@ document.addEventListener('alpine:init', () => {
}, },
})); }));
}); });
// Global "/" shortcut to focus search
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName) && !document.activeElement.isContentEditable) {
e.preventDefault();
const input = document.querySelector('[x-data*="topbarSearch"] input');
if (input) input.focus();
}
});
</script> </script>
@include('partials.afia') @include('partials.afia')
@include('partials.sso-keepalive') @include('partials.sso-keepalive')
@@ -11,6 +11,7 @@
if ($profileMenuItems === [] && $profileUrl !== '#') { if ($profileMenuItems === [] && $profileUrl !== '#') {
$profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]]; $profileMenuItems = [['type' => 'link', 'label' => 'Profile', 'href' => $profileUrl]];
} }
$searchLabel = $searchLabel ?? 'Search';
$navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600'; $navActive = fn (bool $active) => $active ? 'text-indigo-600' : 'text-slate-600';
@endphp @endphp
<div x-data="{ profileOpen: false }" class="lg:hidden"> <div x-data="{ profileOpen: false }" class="lg:hidden">
@@ -26,7 +27,7 @@
<a href="{{ $searchUrl }}" <a href="{{ $searchUrl }}"
class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}"> class="flex flex-col items-center justify-center gap-1 px-2 py-2 transition hover:text-slate-900 {{ $navActive($searchActive ?? false) }}">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg> <svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
<span class="text-[10px] font-medium">Search</span> <span class="text-[10px] font-medium">{{ $searchLabel }}</span>
</a> </a>
@if (!empty($centerCompose)) @if (!empty($centerCompose))
+5 -5
View File
@@ -4,10 +4,10 @@
@include('partials.search-screen', [ @include('partials.search-screen', [
'query' => $query, 'query' => $query,
'results' => $results, 'results' => $results,
'backUrl' => route('events.dashboard'), 'backUrl' => route('mini.dashboard'),
'searchUrl' => route('events.search'), 'searchUrl' => route('mini.search'),
'heading' => 'Find events and programmes', 'heading' => 'Find payments',
'placeholder' => 'Search events, programmes, short codes…', 'placeholder' => 'Search payer, note, reference, QR…',
'emptyHint' => 'Use at least 2 characters to search event names, programme titles, or short codes.', 'emptyHint' => 'Use at least 2 characters to search payer names, notes, references, or QR labels.',
]) ])
</x-user-layout> </x-user-layout>
+2
View File
@@ -11,6 +11,7 @@ use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\AfiaController;
use App\Http\Controllers\Qr\TeamController; use App\Http\Controllers\Qr\TeamController;
use App\Http\Controllers\SearchController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::get('/', fn () => auth()->check() Route::get('/', fn () => auth()->check()
@@ -36,6 +37,7 @@ Route::middleware(['auth'])->group(function () {
Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('mini.dashboard'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('mini.dashboard');
Route::get('/search', SearchController::class)->name('mini.search');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('mini.afia.chat'); Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('mini.afia.chat');
Route::get('/payment-qrs', [PaymentQrController::class, 'index'])->name('mini.payment-qrs.index'); Route::get('/payment-qrs', [PaymentQrController::class, 'index'])->name('mini.payment-qrs.index');
+24 -10
View File
@@ -2,6 +2,7 @@
namespace Tests\Feature; namespace Tests\Feature;
use App\Models\MiniPayment;
use App\Models\QrCode; use App\Models\QrCode;
use App\Models\User; use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -17,25 +18,38 @@ class SearchTest extends TestCase
return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); return User::create(['public_id' => (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']);
} }
public function test_user_can_search_owned_event_by_label(): void public function test_user_can_search_payments_by_payer_name(): void
{ {
$user = $this->user(); $user = $this->user();
QrCode::query()->create([ $qr = QrCode::query()->create([
'user_id' => $user->id, 'user_id' => $user->id,
'short_code' => 'summit', 'short_code' => 'till01',
'type' => QrCode::TYPE_EVENT, 'type' => QrCode::TYPE_PAYMENT,
'label' => 'NextGen Summit', 'label' => 'Main till',
'destination_url' => 'https://example.com', 'destination_url' => 'https://example.com',
'is_active' => true, 'is_active' => true,
]); ]);
MiniPayment::query()->create([
'qr_code_id' => $qr->id,
'user_id' => $user->id,
'reference' => 'pay-'.Str::uuid(),
'amount_minor' => 5000,
'currency' => 'GHS',
'platform_fee_minor' => 250,
'merchant_amount_minor' => 4750,
'payer_name' => 'Ama Mensah',
'status' => MiniPayment::STATUS_PAID,
'paid_at' => now(),
]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
->getJson(route('events.search', ['q' => 'NextGen'])); ->getJson(route('mini.search', ['q' => 'Ama']));
$response->assertOk(); $response->assertOk();
$response->assertJsonPath('results.0.type', 'event'); $response->assertJsonPath('results.0.type', 'payment');
$response->assertJsonPath('results.0.title', 'NextGen Summit'); $response->assertJsonPath('results.0.title', 'Ama Mensah');
} }
public function test_search_page_renders_for_authenticated_user(): void public function test_search_page_renders_for_authenticated_user(): void
@@ -43,8 +57,8 @@ class SearchTest extends TestCase
$user = $this->user(); $user = $this->user();
$this->actingAs($user) $this->actingAs($user)
->get(route('events.search')) ->get(route('mini.search'))
->assertOk() ->assertOk()
->assertSee('Find events and programmes', false); ->assertSee('Find payments', false);
} }
} }