Return an HTML search screen from /search for browser visits so the bottom nav Search tab opens a usable lookup UI instead of raw JSON. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,19 +7,22 @@ use App\Models\CustomerHostingOrder;
|
|||||||
use App\Models\HostingProduct;
|
use App\Models\HostingProduct;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\View\View;
|
||||||
|
|
||||||
class SearchController extends Controller
|
class SearchController extends Controller
|
||||||
{
|
{
|
||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse|View
|
||||||
{
|
{
|
||||||
$q = trim((string) $request->query('q', ''));
|
$q = trim((string) $request->query('q', ''));
|
||||||
|
$results = mb_strlen($q) >= 2 ? $this->resultsForUser($request->user(), $q) : [];
|
||||||
|
|
||||||
if (mb_strlen($q) < 2) {
|
if ($request->expectsJson() || $request->ajax() || $request->wantsJson()) {
|
||||||
return response()->json(['results' => []]);
|
return response()->json(['results' => $results]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return view('search', [
|
||||||
'results' => $this->resultsForUser($request->user(), $q),
|
'query' => $q,
|
||||||
|
'results' => $results,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-3
@@ -136,14 +136,20 @@ Alpine.data('afia', (config = {}) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
Alpine.data('topbarSearch', (config = {}) => ({
|
Alpine.data('topbarSearch', (config = {}) => ({
|
||||||
query: '',
|
query: config.initialQuery || '',
|
||||||
results: [],
|
results: Array.isArray(config.initialResults) ? config.initialResults : [],
|
||||||
open: false,
|
open: !!config.openOnInit,
|
||||||
loading: false,
|
loading: false,
|
||||||
active: 0,
|
active: 0,
|
||||||
_abort: null,
|
_abort: null,
|
||||||
searchUrl: config.searchUrl || '/search',
|
searchUrl: config.searchUrl || '/search',
|
||||||
|
|
||||||
|
init() {
|
||||||
|
if (config.autoFocus) {
|
||||||
|
this.$nextTick(() => this.$refs.input?.focus());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
onFocus() {
|
onFocus() {
|
||||||
if (this.results.length > 0 || this.query.trim().length >= 2) this.open = true;
|
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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
@extends('layouts.hosting')
|
||||||
|
|
||||||
|
@section('title', 'Search')
|
||||||
|
|
||||||
|
@section('content')
|
||||||
|
@include('partials.search-screen', [
|
||||||
|
'query' => $query,
|
||||||
|
'results' => $results,
|
||||||
|
'backUrl' => route('servers.dashboard'),
|
||||||
|
'searchUrl' => route('servers.search'),
|
||||||
|
'heading' => 'Find servers and orders',
|
||||||
|
'placeholder' => 'Search server names, IPs, orders…',
|
||||||
|
'emptyHint' => 'Use at least 2 characters to search VPS orders, server names, or IP addresses.',
|
||||||
|
])
|
||||||
|
@endsection
|
||||||
Reference in New Issue
Block a user