Add mobile and desktop search for QR Plus.
Deploy Ladill QR Plus / deploy (push) Successful in 40s

Introduce a dedicated /search page, wire the bottom nav and topbar to it, and expose JSON results for live lookup of QR codes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 15:37:58 +00:00
co-authored by Cursor
parent de2ad0d393
commit 5fb2de31c8
8 changed files with 253 additions and 58 deletions
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers;
use App\Models\QrCode;
use App\Support\Qr\QrTypeCatalog;
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
{
$like = '%'.$q.'%';
return ladill_account()->qrCodes()
->whereIn('type', QrTypeCatalog::plusTypes())
->where(function ($query) use ($like): void {
$query->where('label', 'like', $like)
->orWhere('short_code', 'like', $like)
->orWhere('destination_url', 'like', $like);
})
->orderByDesc('updated_at')
->limit(15)
->get()
->map(fn (QrCode $code): array => [
'type' => 'qr',
'title' => $code->label,
'subtitle' => $code->typeLabel().' · '.$code->publicUrl(),
'url' => route('user.qr-codes.show', $code),
])
->all();
}
}
+9 -3
View File
@@ -140,14 +140,20 @@ Alpine.data('afia', (config = {}) => ({
}));
Alpine.data('topbarSearch', (config = {}) => ({
query: '',
results: [],
open: false,
query: config.initialQuery || '',
results: Array.isArray(config.initialResults) ? config.initialResults : [],
open: !!config.openOnInit,
loading: false,
active: 0,
_abort: null,
searchUrl: config.searchUrl || '/search',
init() {
if (config.autoFocus) {
this.$nextTick(() => this.$refs.input?.focus());
}
},
onFocus() {
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
},
@@ -0,0 +1,28 @@
@props([
'title',
'subtitle' => null,
'backUrl' => null,
'badge' => null,
])
<div class="sticky top-0 z-20 border-b border-slate-200 bg-white/95 backdrop-blur lg:hidden">
<div class="flex items-center gap-3 px-4 py-3">
@if ($backUrl)
<a href="{{ $backUrl }}"
class="inline-flex h-10 w-10 items-center justify-center rounded-full border border-slate-200 text-slate-600 transition hover:bg-slate-50">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18"/></svg>
</a>
@endif
<div class="min-w-0 flex-1">
@if ($subtitle)
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">{{ $subtitle }}</p>
@endif
<h1 class="truncate text-base font-semibold text-slate-900">{{ $title }}</h1>
</div>
@if ($badge)
<span class="rounded-full bg-slate-100 px-3 py-1 text-xs font-medium text-slate-600">{{ $badge }}</span>
@endif
{{ $actions ?? '' }}
</div>
{{ $slot }}
</div>
+4 -55
View File
@@ -1,7 +1,8 @@
@php
$mobileFullScreenPage = request()->routeIs('account.settings')
|| request()->routeIs('user.qr-codes.create')
|| request()->routeIs('user.qr-codes.show');
|| request()->routeIs('user.qr-codes.show')
|| request()->routeIs('qr.search');
$qrMobilePage = request()->routeIs('user.qr-codes.create') || request()->routeIs('user.qr-codes.show');
@endphp
@@ -65,8 +66,8 @@
@include('partials.mobile-bottom-nav', [
'homeUrl' => route('qr.dashboard'),
'homeActive' => request()->routeIs('qr.dashboard'),
'searchUrl' => route('user.qr-codes.index'),
'searchActive' => request()->routeIs('user.qr-codes.*'),
'searchUrl' => route('qr.search'),
'searchActive' => request()->routeIs('qr.search'),
'notificationsUrl' => route('notifications.index'),
'notificationsActive' => request()->routeIs('notifications.*'),
'unreadUrl' => route('notifications.unread'),
@@ -90,58 +91,6 @@
</div>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('topbarSearch', (config = {}) => ({
query: config.initialQuery || '',
results: Array.isArray(config.initialResults) ? config.initialResults : [],
open: !!config.openOnInit,
loading: false,
active: 0,
_abort: null,
init() {
if (config.autoFocus) {
this.$nextTick(() => this.$refs.input?.focus());
}
},
onFocus() {
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
},
async search() {
const q = this.query.trim();
if (q.length < 2) { this.results = []; this.open = false; return; }
this.loading = true;
this.open = true;
this.active = 0;
if (this._abort) this._abort.abort();
this._abort = new AbortController();
try {
const res = await fetch(`/search?q=${encodeURIComponent(q)}`, {
signal: this._abort.signal,
headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
const data = await res.json();
this.results = data.results || [];
} catch (e) {
if (e.name !== 'AbortError') this.results = [];
} finally {
this.loading = false;
}
},
moveDown() { if (this.active < this.results.length - 1) this.active++; },
moveUp() { if (this.active > 0) this.active--; },
go() {
if (this.results[this.active]) {
window.location.href = this.results[this.active].url;
}
}
}));
Alpine.data('notificationDropdown', (config = {}) => ({
open: false,
loading: false,
@@ -0,0 +1,100 @@
@php
$searchUrl = $searchUrl ?? url('/search');
@endphp
<div class="min-h-full bg-white lg:min-h-0 lg:bg-transparent"
x-data="topbarSearch({
searchUrl: @js($searchUrl),
initialQuery: @js($query),
initialResults: @js($results),
openOnInit: @js($query !== ''),
autoFocus: true
})">
<x-mobile-page-header :title="$heading" subtitle="Search" :back-url="$backUrl">
<div class="px-4 pb-3">
<div class="relative">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" 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>
<input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="{{ $placeholder }}"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
</div>
</div>
</x-mobile-page-header>
<div class="sticky top-0 z-20 hidden border-b border-slate-200 bg-white/95 backdrop-blur lg:block">
<div class="mx-auto max-w-4xl px-6 py-3">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">Search</p>
<h1 class="text-2xl font-semibold text-slate-900">{{ $heading }}</h1>
<div class="relative mt-3">
<svg class="pointer-events-none absolute left-4 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" 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>
<input x-ref="input"
type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="{{ $placeholder }}"
class="w-full rounded-2xl border border-slate-200 bg-slate-50 py-3 pl-12 pr-4 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
</div>
</div>
</div>
<div class="mx-auto max-w-4xl px-4 py-4 pb-28 lg:px-6 lg:py-6 lg:pb-6">
<template x-if="loading">
<div class="rounded-2xl border border-slate-200 bg-white px-4 py-8 text-center text-sm text-slate-500 shadow-sm">
Searching…
</div>
</template>
<template x-if="!loading && query.trim().length < 2">
<div class="rounded-2xl border border-dashed border-slate-200 bg-slate-50 px-5 py-10 text-center">
<p class="text-sm font-medium text-slate-900">Start typing to search</p>
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
</div>
</template>
<template x-if="!loading && query.trim().length >= 2 && results.length === 0">
<div class="rounded-2xl border border-slate-200 bg-white px-5 py-10 text-center shadow-sm">
<p class="text-sm font-medium text-slate-900">No results for "<span x-text="query"></span>"</p>
<p class="mt-1 text-sm text-slate-500">{{ $emptyHint }}</p>
</div>
</template>
<template x-if="!loading && results.length > 0">
<div class="space-y-3">
<template x-for="(item, idx) in results" :key="item.url">
<a :href="item.url"
:class="idx === active ? 'border-indigo-200 bg-indigo-50/70 shadow-sm' : 'border-slate-200 bg-white'"
@mouseenter="active = idx"
class="flex items-start gap-4 rounded-2xl border px-4 py-4 transition hover:border-indigo-200 hover:bg-indigo-50/60">
<span class="mt-0.5 flex h-11 w-11 shrink-0 items-center justify-center rounded-2xl"
:class="{
'bg-violet-100 text-violet-600': item.type === 'event',
'bg-indigo-100 text-indigo-600': item.type === 'programme',
'bg-fuchsia-100 text-fuchsia-600': item.type === 'qr',
'bg-teal-100 text-teal-600': item.type === 'hosting',
'bg-orange-100 text-orange-600': item.type === 'order',
'bg-slate-100 text-slate-600': !['event','programme','qr','hosting','order'].includes(item.type),
}">
<svg class="h-5 w-5" fill="none" stroke="currentColor" stroke-width="1.8" 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>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-semibold text-slate-900" x-text="item.title"></span>
<span class="mt-1 block text-sm text-slate-500" x-text="item.subtitle"></span>
</span>
<svg class="mt-1 h-5 w-5 shrink-0 text-slate-300" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m9 5 7 7-7 7"/></svg>
</a>
</template>
</div>
</template>
</div>
</div>
@@ -13,6 +13,52 @@
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<p class="truncate text-sm font-medium text-slate-700 lg:hidden">Ladill QR Plus</p>
<div class="relative hidden min-w-0 flex-1 max-w-md lg:block"
x-data="topbarSearch({ searchUrl: @js(route('qr.search')) })"
@click.outside="open = false"
@keydown.escape.window="open = false">
<div class="relative">
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" 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>
<input type="text"
x-model="query"
@focus="onFocus()"
@input.debounce.200ms="search()"
@keydown.arrow-down.prevent="moveDown()"
@keydown.arrow-up.prevent="moveUp()"
@keydown.enter.prevent="go()"
placeholder="Search QR codes…"
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
</div>
<div x-show="open && (results.length > 0 || (query.length >= 2 && !loading))"
x-cloak
x-transition.opacity.duration.150ms
class="absolute left-0 top-full z-30 mt-1.5 w-full overflow-hidden rounded-xl border border-slate-200 bg-white shadow-lg">
<template x-if="loading">
<div class="px-4 py-3 text-center text-xs text-slate-400">Searching…</div>
</template>
<template x-if="!loading && results.length === 0 && query.length >= 2">
<div class="px-4 py-3 text-center text-xs text-slate-500">No results for "<span x-text="query" class="font-medium"></span>"</div>
</template>
<template x-if="!loading && results.length > 0">
<ul class="max-h-72 overflow-y-auto py-1">
<template x-for="(item, idx) in results" :key="item.url">
<li>
<a :href="item.url"
:class="idx === active ? 'bg-indigo-50 text-indigo-700' : 'text-slate-700'"
@mouseenter="active = idx"
class="flex items-center gap-3 px-4 py-2.5 text-sm transition hover:bg-indigo-50">
<span class="min-w-0 flex-1">
<span class="block truncate font-medium leading-tight" x-text="item.title"></span>
<span class="block truncate text-xs text-slate-400" x-text="item.subtitle"></span>
</span>
</a>
</li>
</template>
</ul>
</template>
</div>
</div>
</div>
<div class="flex items-center gap-3">
+13
View File
@@ -0,0 +1,13 @@
<x-user-layout>
<x-slot name="title">Search</x-slot>
@include('partials.search-screen', [
'query' => $query,
'results' => $results,
'backUrl' => route('qr.dashboard'),
'searchUrl' => route('qr.search'),
'heading' => 'Find QR codes',
'placeholder' => 'Search QR codes, short codes, URLs…',
'emptyHint' => 'Use at least 2 characters to search labels, short codes, or destinations.',
])
</x-user-layout>
+2
View File
@@ -9,6 +9,7 @@ use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Qr\OverviewController;
use App\Http\Controllers\Qr\TeamController;
use App\Http\Controllers\Qr\QrCodeController;
use App\Http\Controllers\SearchController;
use Illuminate\Support\Facades\Route;
Route::get('/', fn () => auth()->check()
@@ -37,6 +38,7 @@ Route::middleware(['auth'])->group(function () {
Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat');
Route::get('/search', SearchController::class)->name('qr.search');
Route::get('/qr-codes', [QrCodeController::class, 'index'])->name('user.qr-codes.index');
Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create');