Add mobile-only payment search for Ladill Mini.
Deploy Ladill Mini / deploy (push) Successful in 24s
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:
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user