SPB Git forge

spb/market-atlas

Public
12commits 1branches 0releases
1.1 MBsize
maindefault branch
10 days agolast push
TypeScript 96.7% SQL 1.6% CSS 0.8% JavaScript 0.5%
7.4 KB · 173 lines tsx
Raw Blame History
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