spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1"use client";23import { Search, X } from "lucide-react";4import { useRouter } from "next/navigation";5import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";6import { clientApi } from "@/lib/client-api";7import { ASSET_CLASS_LABEL, cx, formatPercent, formatQuoteValue, instrumentHref } from "@/lib/format";8import type { SearchResult } from "@/lib/types";910const Ctx = createContext<{ open: () => void }>({ open: () => {} });11export const useSearch = () => useContext(Ctx);1213interface Hit {14 group: string;15 href: string;16 title: string;17 subtitle: string;18 right?: string;19 rightTone?: "pos" | "neg" | "neutral";20}2122function toHits(r: SearchResult): Hit[] {23 return r.results.map(({ group, item }) => {24 const it = item as Record<string, any>;25 if (group === "EXCHANGE") return { group, href: `/exchanges/${it.id}`, title: it.name, subtitle: `${it.mic ?? ""} · ${it.country} · ${String(it.status).toLowerCase()}`, right: "" };26 if (group === "COUNTRY") return { group, href: `/countries/${it.code}`, title: it.name, subtitle: it.region, right: "" };27 const q = it.quote as Record<string, any> | null;28 const pct = q?.change_percent as number | null | undefined;29 return {30 group,31 href: instrumentHref(it.id),32 title: it.symbol,33 subtitle: `${it.name} · ${ASSET_CLASS_LABEL[it.asset_class] ?? it.asset_class}${it.exchange_id ? ` · ${String(it.exchange_id).toUpperCase()}` : ""}`,34 right: q?.price != null ? `${formatQuoteValue(q.price, it.asset_class, q.currency)} ${formatPercent(pct)}` : "",35 rightTone: pct == null ? "neutral" : pct > 0 ? "pos" : pct < 0 ? "neg" : "neutral",36 };37 });38}3940export function SearchProvider({ children }: { children: ReactNode }) {41 const [open, setOpen] = useState(false);42 const [q, setQ] = useState("");43 const [hits, setHits] = useState<Hit[]>([]);44 const [idx, setIdx] = useState(0);45 const [loading, setLoading] = useState(false);46 const router = useRouter();47 const inputRef = useRef<HTMLInputElement>(null);48 const seq = useRef(0);4950 useEffect(() => {51 const onKey = (e: KeyboardEvent) => {52 if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {53 e.preventDefault();54 setOpen((o) => !o);55 } else if (e.key === "Escape") setOpen(false);56 };57 window.addEventListener("keydown", onKey);58 return () => window.removeEventListener("keydown", onKey);59 }, []);6061 useEffect(() => {62 if (!open) return;63 setTimeout(() => inputRef.current?.focus(), 20);64 }, [open]);6566 useEffect(() => {67 if (!open) return;68 const term = q.trim();69 if (!term) {70 setHits([]);71 return;72 }73 const my = ++seq.current;74 setLoading(true);75 const t = setTimeout(() => {76 clientApi<SearchResult>(`/v1/search?q=${encodeURIComponent(term)}&limit=14`)77 .then((r) => {78 if (my !== seq.current) return;79 setHits(toHits(r));80 setIdx(0);81 })82 .catch(() => {})83 .finally(() => my === seq.current && setLoading(false));84 }, 140);85 return () => clearTimeout(t);86 }, [q, open]);8788 const go = useCallback(89 (h: Hit | undefined) => {90 if (!h) {91 if (q.trim()) router.push(`/search?q=${encodeURIComponent(q.trim())}`);92 } else router.push(h.href);93 setOpen(false);94 setQ("");95 },96 [router, q],97 );9899 const ctx = useMemo(() => ({ open: () => setOpen(true) }), []);100 return (101 <Ctx.Provider value={ctx}>102 {children}103 {open && (104 <div role="dialog" aria-modal="true" aria-label="Search" data-palette className="fixed inset-0 z-[150] flex items-start justify-center bg-black/40 p-3 pt-[10vh] backdrop-blur-[2px]" onClick={() => setOpen(false)}>105 <div className="panel w-full max-w-xl overflow-hidden shadow-2xl" onClick={(e) => e.stopPropagation()}>106 <div className="flex items-center gap-2 border-b border-rule px-3">107 <Search size={16} className="text-ink-3" />108 <input109 ref={inputRef}110 value={q}111 onChange={(e) => setQ(e.target.value)}112 onKeyDown={(e) => {113 if (e.key === "ArrowDown") {114 e.preventDefault();115 setIdx((i) => Math.min(hits.length - 1, i + 1));116 } else if (e.key === "ArrowUp") {117 e.preventDefault();118 setIdx((i) => Math.max(0, i - 1));119 } else if (e.key === "Enter") go(hits[idx]);120 }}121 placeholder="Search AAPL, Bitcoin, S&P 500, Toronto Stock Exchange, US 10Y…"122 className="h-12 flex-1 bg-transparent text-[15px] outline-none placeholder:text-ink-3"123 autoComplete="off"124 />125 <button type="button" onClick={() => setOpen(false)} aria-label="Close" className="flex h-11 w-11 items-center justify-center text-ink-3 hover:text-ink">126 <X size={16} />127 </button>128 </div>129 <div className="max-h-[60vh] overflow-y-auto">130 {!q.trim() && (131 <div className="px-4 py-3 text-xs text-ink-3">132 Type a symbol, company, index, currency pair, exchange or country. <kbd className="mono rounded border border-rule px-1">↑↓</kbd> to move, <kbd className="mono rounded border border-rule px-1">↵</kbd> to open.133 </div>134 )}135 {q.trim() && !hits.length && !loading && <div className="px-4 py-6 text-center text-sm text-ink-3">No results for “{q}”.</div>}136 {hits.map((h, i) => (137 <button key={h.href + i} type="button" onMouseEnter={() => setIdx(i)} onClick={() => go(h)} className={cx("flex w-full items-center gap-3 px-4 py-2.5 text-left", i === idx ? "bg-accent-soft" : "hover:bg-surface-2")}>138 <span className="mono w-16 shrink-0 text-[10px] uppercase tracking-wide text-ink-3">{ASSET_CLASS_LABEL[h.group] ?? h.group.toLowerCase()}</span>139 <span className="min-w-0 flex-1">140 <span className="block truncate text-sm font-medium">{h.title}</span>141 <span className="block truncate text-xs text-ink-3">{h.subtitle}</span>142 </span>143 {h.right && <span className={cx("mono whitespace-pre text-xs", h.rightTone === "pos" ? "text-positive" : h.rightTone === "neg" ? "text-negative" : "text-ink-2")}>{h.right}</span>}144 </button>145 ))}146 {q.trim() && (147 <button type="button" onClick={() => go(undefined)} className="w-full border-t border-rule px-4 py-2.5 text-left text-xs text-accent hover:bg-surface-2">148 See all results for “{q}” →149 </button>150 )}151 </div>152 </div>153 </div>154 )}155 </Ctx.Provider>156 );157}158159export function SearchButton({ className, compact }: { className?: string; compact?: boolean }) {160 const { open } = useSearch();161 return (162 <button type="button" onClick={open} className={cx("inline-flex h-11 items-center gap-2 rounded-md border border-rule bg-surface px-3 text-sm text-ink-3 hover:border-rule-strong hover:text-ink", className)} aria-label="Search">163 <Search size={15} />164 {!compact && (165 <>166 <span className="hidden sm:inline">Search markets</span>167 <kbd className="mono ml-2 hidden rounded border border-rule px-1 text-[10px] sm:inline">⌘K</kbd>168 </>169 )}170 </button>171 );172}173