From a0bcf93e53b820bc94fe9546ac20b4b790a20143 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 6 Jun 2026 18:06:58 +0000 Subject: [PATCH] Add live topbar search for hosting accounts and sites. Wire a JSON /search endpoint and Alpine dropdown so customers can find accounts, linked domains, and orders from the hosting app header. Co-authored-by: Cursor --- .../Controllers/Hosting/SearchController.php | 104 ++++++++++++++++++ resources/js/app.js | 45 ++++++++ resources/views/partials/topbar.blade.php | 35 +++++- routes/web.php | 2 + 4 files changed, 180 insertions(+), 6 deletions(-) create mode 100644 app/Http/Controllers/Hosting/SearchController.php diff --git a/app/Http/Controllers/Hosting/SearchController.php b/app/Http/Controllers/Hosting/SearchController.php new file mode 100644 index 0000000..44eab55 --- /dev/null +++ b/app/Http/Controllers/Hosting/SearchController.php @@ -0,0 +1,104 @@ +query('q', '')); + + if (mb_strlen($q) < 2) { + return response()->json(['results' => []]); + } + + return response()->json([ + 'results' => $this->resultsForUser($request->user(), $q), + ]); + } + + /** @return list */ + private function resultsForUser($user, string $q): array + { + $like = '%'.$q.'%'; + $results = []; + $seenUrls = []; + + $push = function (array $item) use (&$results, &$seenUrls): void { + if (isset($seenUrls[$item['url']])) { + return; + } + $seenUrls[$item['url']] = true; + $results[] = $item; + }; + + $sharedTypes = [ + HostingProduct::TYPE_SINGLE_DOMAIN, + HostingProduct::TYPE_MULTI_DOMAIN, + HostingProduct::TYPE_WORDPRESS, + ]; + + $sharedAccountIds = HostingAccountMember::query() + ->where('user_id', $user->id) + ->pluck('hosting_account_id'); + + HostingAccount::query() + ->where(function ($query) use ($user, $sharedAccountIds) { + $query->where('user_id', $user->id); + if ($sharedAccountIds->isNotEmpty()) { + $query->orWhereIn('id', $sharedAccountIds); + } + }) + ->whereHas('product', fn ($query) => $query->whereIn('type', $sharedTypes)) + ->where(function ($query) use ($like) { + $query->where('username', 'like', $like) + ->orWhere('primary_domain', 'like', $like) + ->orWhereHas('sites', fn ($sites) => $sites->where('domain', 'like', $like)); + }) + ->with(['product', 'sites']) + ->orderByDesc('updated_at') + ->limit(8) + ->get() + ->each(function (HostingAccount $account) use ($push, $user): void { + $sharedDeveloperAccess = (int) $account->user_id !== (int) $user->id; + $push([ + 'type' => 'hosting', + 'title' => $account->linkedDomainLabel() ?? $account->username, + 'subtitle' => ($account->product?->name ?? 'Hosting').' · '.ucfirst((string) $account->status), + 'url' => $sharedDeveloperAccess + ? route('hosting.panel.index', $account) + : route('hosting.accounts.show', $account), + ]); + }); + + CustomerHostingOrder::query() + ->forUser($user->id) + ->whereHas('product', fn ($query) => $query->whereIn('type', $sharedTypes)) + ->where(function ($query) use ($like) { + $query->where('domain_name', 'like', $like) + ->orWhere('server_ip', 'like', $like); + }) + ->with('product') + ->orderByDesc('updated_at') + ->limit(5) + ->get() + ->each(function (CustomerHostingOrder $order) use ($push): void { + $push([ + 'type' => 'order', + 'title' => $order->domain_name ?: ($order->product?->name ?? 'Hosting order #'.$order->id), + 'subtitle' => 'Hosting order · '.ucwords(str_replace('_', ' ', (string) $order->status)), + 'url' => route('hosting.orders.show', $order), + ]); + }); + + return array_slice($results, 0, 12); + } +} diff --git a/resources/js/app.js b/resources/js/app.js index 2a66a04..afa6faf 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -50,5 +50,50 @@ Alpine.data('afia', (config = {}) => ({ }, })); +Alpine.data('topbarSearch', (config = {}) => ({ + query: '', + results: [], + open: false, + loading: false, + active: 0, + _abort: null, + searchUrl: config.searchUrl || '/search', + + 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(`${this.searchUrl}?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; + }, +})); + window.Alpine = Alpine; Alpine.start(); diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php index 3d872f1..c50e487 100644 --- a/resources/views/partials/topbar.blade.php +++ b/resources/views/partials/topbar.blade.php @@ -4,12 +4,35 @@ - {{-- Search mailboxes --}} - + {{-- Search hosting accounts, domains, orders --}} +
@auth diff --git a/routes/web.php b/routes/web.php index a7c45d4..a9d6b65 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,6 +9,7 @@ use App\Http\Controllers\Hosting\HostingPanelController; use App\Http\Controllers\Hosting\HostingProductController; use App\Http\Controllers\Hosting\HostingTerminalSessionController; use App\Http\Controllers\Hosting\OverviewController; +use App\Http\Controllers\Hosting\SearchController; use App\Http\Controllers\Hosting\TeamController; use Illuminate\Support\Facades\Route; @@ -28,6 +29,7 @@ Route::get('/sso/logout-frontchannel', [SsoLoginController::class, 'frontchannel Route::get('/signed-out', fn () => auth()->check() ? redirect()->route('hosting.dashboard') : view('hosting.signed-out'))->name('hosting.signed-out'); Route::middleware(['auth'])->group(function () use ($serversApp) { + Route::get('/search', SearchController::class)->name('hosting.search'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('hosting.dashboard'); Route::redirect('/hosting', '/dashboard')->name('hosting.index');