"use client"; import { Search, X } from "lucide-react"; import { useRouter } from "next/navigation"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { clientApi } from "@/lib/client-api"; import { ASSET_CLASS_LABEL, cx, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format"; import type { SearchResult } from "@/lib/types"; const Ctx = createContext<{ open: () => void }>({ open: () => {} }); export const useSearch = () => useContext(Ctx); interface Hit { group: string; href: string; title: string; subtitle: string; right?: string; rightTone?: "pos" | "neg" | "neutral"; } function toHits(r: SearchResult): Hit[] { return r.results.map(({ group, item }) => { const it = item as Record; if (group === "EXCHANGE") return { group, href: `/exchanges/${it.id}`, title: it.name, subtitle: `${it.mic ?? ""} · ${it.country} · ${String(it.status).toLowerCase()}`, right: "" }; if (group === "COUNTRY") return { group, href: `/countries/${it.code}`, title: it.name, subtitle: it.region, right: "" }; const q = it.quote as Record | null; const pct = q?.change_percent as number | null | undefined; return { group, href: instrumentHref(it.id), title: it.symbol, subtitle: `${it.name} · ${ASSET_CLASS_LABEL[it.asset_class] ?? it.asset_class}${it.exchange_id ? ` · ${String(it.exchange_id).toUpperCase()}` : ""}`, right: q?.price != null ? `${formatQuoteValue(q.price, it.asset_class, q.currency)} ${formatPercent(pct)}` : "", rightTone: pct == null ? "neutral" : pct > 0 ? "pos" : pct < 0 ? "neg" : "neutral", }; }); } export function SearchProvider({ children }: { children: ReactNode }) { const [open, setOpen] = useState(false); const [q, setQ] = useState(""); const [hits, setHits] = useState([]); const [idx, setIdx] = useState(0); const [loading, setLoading] = useState(false); const router = useRouter(); const inputRef = useRef(null); const seq = useRef(0); useEffect(() => { const onKey = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { e.preventDefault(); setOpen((o) => !o); } else if (e.key === "Escape") setOpen(false); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); useEffect(() => { if (!open) return; setTimeout(() => inputRef.current?.focus(), 20); }, [open]); useEffect(() => { if (!open) return; const term = q.trim(); if (!term) { setHits([]); return; } const my = ++seq.current; setLoading(true); const t = setTimeout(() => { clientApi(`/v1/search?q=${encodeURIComponent(term)}&limit=14`) .then((r) => { if (my !== seq.current) return; setHits(toHits(r)); setIdx(0); }) .catch(() => {}) .finally(() => my === seq.current && setLoading(false)); }, 140); return () => clearTimeout(t); }, [q, open]); const go = useCallback( (h: Hit | undefined) => { if (!h) { if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`); } else router.push(h.href); setOpen(false); setQ(""); }, [router, q], ); const ctx = useMemo(() => ({ open: () => setOpen(true) }), []); return ( {children} {open && (
setOpen(false)}>
e.stopPropagation()}>
setQ(e.target.value)} onKeyDown={(e) => { if (e.key === "ArrowDown") { e.preventDefault(); setIdx((i) => Math.min(hits.length - 1, i + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); setIdx((i) => Math.max(0, i - 1)); } else if (e.key === "Enter") go(hits[idx]); }} placeholder="Search AAPL, Bitcoin, S&P 500, Toronto Stock Exchange, US 10Y…" className="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-ink-3" autoComplete="off" />
{!q.trim() && (
Type a symbol, company, index, currency pair, exchange or country. ↑↓ to move, ↵ to open.
)} {q.trim() && !hits.length && !loading &&
No results for “{q}”.
} {hits.map((h, i) => ( ))} {q.trim() && ( )}
)}
); } export function SearchButton({ className, compact }: { className?: string; compact?: boolean }) { const { open } = useSearch(); return ( ); }