"use client"; // Global conversation search in the topbar: type → debounced crm.search → dropdown of hits; click a // hit to deep-link to exactly where it lives (mail → Inbox, chat → Messenger, on that thread). import { useEffect, useRef, useState } from "react"; import { Icon } from "./ui"; import { useGlobalSearch, type SearchResult } from "@/lib/search-api"; /** Escape HTML but keep the engine's highlight tags — so a match snippet can't inject markup. */ function safeSnippet(s: string): string { const esc = s.replace(/&/g, "&").replace(//g, ">"); return esc.replace(/<em>/g, "").replace(/<\/em>/g, ""); } export function GlobalSearch({ onNavigate }: { onNavigate: (surface: "messenger" | "inbox", threadId: string) => void }) { const search = useGlobalSearch(); const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); const wrapRef = useRef(null); useEffect(() => { const term = q.trim(); if (!term) { setResults([]); return; } let alive = true; setLoading(true); const t = setTimeout(() => { search(term) .then((r) => { if (alive) setResults(r); }) .catch(() => { if (alive) setResults([]); }) .finally(() => { if (alive) setLoading(false); }); }, 220); return () => { alive = false; clearTimeout(t); }; }, [q, search]); useEffect(() => { function onDown(e: MouseEvent) { if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", onDown); return () => document.removeEventListener("mousedown", onDown); }, []); function pick(r: SearchResult) { onNavigate(r.surface, r.threadId); setOpen(false); setQ(""); } return (
setOpen(true)} onChange={(e) => { setQ(e.target.value); setOpen(true); }} aria-label="Search conversations" />
{open && q.trim() ? (
{loading && results.length === 0 ?
Searching…
: null} {!loading && results.length === 0 ?
No matches.
: null} {results.map((r) => ( ))}
) : null}
); }