132 lines
4.3 KiB
TypeScript
132 lines
4.3 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import Link from "next/link";
|
|
|
|
interface Hit {
|
|
type: "user" | "trip" | "booking";
|
|
id: string;
|
|
title: string;
|
|
subtitle: string;
|
|
href: string;
|
|
}
|
|
|
|
/**
|
|
* Search bar global untuk admin sidebar. Debounced 250ms supaya tidak spam
|
|
* server. Hits dispatch berdasarkan pola input — lihat
|
|
* `adminSearchService.resolve` di server.
|
|
*/
|
|
export function AdminSearchBar() {
|
|
const [query, setQuery] = useState("");
|
|
const [hits, setHits] = useState<Hit[]>([]);
|
|
const [open, setOpen] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
// Debounced fetch — guard inside async block supaya tidak setState langsung
|
|
// di effect synchronous (react-hooks/set-state-in-effect).
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => {
|
|
if (q.length < 2) {
|
|
setHits([]);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
setLoading(true);
|
|
fetch(`/api/admin/search?q=${encodeURIComponent(q)}`, {
|
|
signal: controller.signal,
|
|
})
|
|
.then((res) => (res.ok ? res.json() : { hits: [] }))
|
|
.then((json: { hits: Hit[] }) => {
|
|
setHits(json.hits ?? []);
|
|
})
|
|
.catch(() => setHits([]))
|
|
.finally(() => setLoading(false));
|
|
}, 250);
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, [query]);
|
|
|
|
// Close dropdown on outside click
|
|
useEffect(() => {
|
|
function onClick(e: MouseEvent) {
|
|
if (
|
|
wrapperRef.current &&
|
|
!wrapperRef.current.contains(e.target as Node)
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
}
|
|
document.addEventListener("mousedown", onClick);
|
|
return () => document.removeEventListener("mousedown", onClick);
|
|
}, []);
|
|
|
|
return (
|
|
<div ref={wrapperRef} className="relative">
|
|
<input
|
|
type="search"
|
|
value={query}
|
|
onChange={(e) => {
|
|
setQuery(e.target.value);
|
|
setOpen(true);
|
|
}}
|
|
onFocus={() => setOpen(true)}
|
|
placeholder="Cari email, ID, order_id, judul..."
|
|
className="w-full rounded-lg border border-neutral-200 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800 placeholder:text-neutral-400 focus:border-primary-400 focus:bg-white"
|
|
/>
|
|
{open && query.trim().length >= 2 && (
|
|
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-80 overflow-y-auto rounded-xl border border-neutral-200 bg-white shadow-xl">
|
|
{loading && (
|
|
<p className="px-3 py-2 text-[11px] text-neutral-500">Mencari...</p>
|
|
)}
|
|
{!loading && hits.length === 0 && (
|
|
<p className="px-3 py-2 text-[11px] text-neutral-500">
|
|
Tidak ada hasil.
|
|
</p>
|
|
)}
|
|
{!loading && hits.length > 0 && (
|
|
<ul className="py-1">
|
|
{hits.map((h) => (
|
|
<li key={`${h.type}-${h.id}`}>
|
|
<Link
|
|
href={h.href}
|
|
onClick={() => {
|
|
setOpen(false);
|
|
setQuery("");
|
|
}}
|
|
className="flex items-center gap-2 px-3 py-2 hover:bg-neutral-50"
|
|
>
|
|
<span
|
|
className={`rounded-full px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wide ${
|
|
h.type === "user"
|
|
? "bg-primary-100 text-primary-700"
|
|
: h.type === "trip"
|
|
? "bg-secondary-100 text-secondary-700"
|
|
: "bg-amber-100 text-amber-700"
|
|
}`}
|
|
>
|
|
{h.type}
|
|
</span>
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-xs font-semibold text-neutral-800">
|
|
{h.title}
|
|
</p>
|
|
<p className="truncate text-[10px] text-neutral-500">
|
|
{h.subtitle}
|
|
</p>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|