← back to Norma

components/PulseSearch.tsx

377 lines

'use client';

/* ═══════════════════════════════════════════════════════════════════════════
   PulseSearch — public-tier global search bar for the Pulse layout.

   Distinct from <GlobalSearch /> (which is dark-themed for the Norma admin /
   Nikki staff shells). PulseSearch matches the Pulse "Pulse of America"
   theme: Georgia serif, cream / dark-green / gold palette, lighter typography.

   Hits /api/search/public — NO auth required. Only returns entities that are
   safe for unauthenticated end-users (petitions, politicians, organizations,
   topics, articles). Never journalists/contacts/grants/foundations.

   Keyboard: ⌘K / Ctrl+K opens, Esc closes, ↑/↓ navigate, Enter selects.
   ═══════════════════════════════════════════════════════════════════════════ */

import { useState, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import {
  Search, X, FileText, Vote, Building2, Radio, Newspaper, ArrowRight,
} from 'lucide-react';

interface SearchResult {
  id: string;
  type: 'petition' | 'politician' | 'organization' | 'topic' | 'article';
  title: string;
  subtitle: string;
  meta?: string;
  url?: string;
}

interface SearchResponse {
  results: SearchResult[];
  query: string;
  counts: Record<string, number>;
  total: number;
}

interface PulseTheme {
  darkGreen: string;
  gold: string;
  goldHover: string;
  cream: string;
  textLight: string;
}

const TYPE_CONFIG: Record<SearchResult['type'], { icon: React.ElementType; color: string; label: string }> = {
  petition:     { icon: FileText,   color: '#e11d48', label: 'Petition'     },
  politician:   { icon: Vote,       color: '#1d4ed8', label: 'Politician'   },
  organization: { icon: Building2,  color: '#0a7c59', label: 'Organization' },
  topic:        { icon: Radio,      color: '#0891b2', label: 'Topic'        },
  article:      { icon: Newspaper,  color: '#6366f1', label: 'Article'      },
};

export default function PulseSearch({ theme }: { theme: PulseTheme }) {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState('');
  const [results, setResults] = useState<SearchResult[]>([]);
  const [counts, setCounts] = useState<Record<string, number>>({});
  const [loading, setLoading] = useState(false);
  const [activeFilter, setActiveFilter] = useState('');
  const [selectedIndex, setSelectedIndex] = useState(0);
  const inputRef = useRef<HTMLInputElement>(null);
  const panelRef = useRef<HTMLDivElement>(null);
  const router = useRouter();

  /* ── Keyboard shortcut: Cmd+K / Ctrl+K ────────────────────────────────── */
  useEffect(() => {
    function handleKey(e: KeyboardEvent) {
      if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
        e.preventDefault();
        setOpen(true);
      }
      if (e.key === 'Escape') {
        setOpen(false);
        setQuery('');
        setResults([]);
      }
    }
    window.addEventListener('keydown', handleKey);
    return () => window.removeEventListener('keydown', handleKey);
  }, []);

  /* ── Focus input when opened ──────────────────────────────────────────── */
  useEffect(() => {
    if (open) setTimeout(() => inputRef.current?.focus(), 50);
  }, [open]);

  /* ── Close on outside click ───────────────────────────────────────────── */
  useEffect(() => {
    if (!open) return;
    function handleClick(e: MouseEvent) {
      if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
        setOpen(false);
      }
    }
    document.addEventListener('mousedown', handleClick);
    return () => document.removeEventListener('mousedown', handleClick);
  }, [open]);

  /* ── Debounced search ─────────────────────────────────────────────────── */
  useEffect(() => {
    if (query.length < 2) {
      setResults([]);
      setCounts({});
      return;
    }
    setLoading(true);
    const timer = setTimeout(async () => {
      try {
        const res = await fetch(`/api/search/public?q=${encodeURIComponent(query)}&limit=20`);
        if (res.ok) {
          const data: SearchResponse = await res.json();
          setResults(data.results);
          setCounts(data.counts);
          setSelectedIndex(0);
        }
      } catch { /* ignore */ }
      setLoading(false);
    }, 250);
    return () => clearTimeout(timer);
  }, [query]);

  const filtered = activeFilter ? results.filter(r => r.type === activeFilter) : results;

  const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      setSelectedIndex(i => Math.min(i + 1, filtered.length - 1));
    } else if (e.key === 'ArrowUp') {
      e.preventDefault();
      setSelectedIndex(i => Math.max(i - 1, 0));
    } else if (e.key === 'Enter' && filtered[selectedIndex]) {
      e.preventDefault();
      handleSelect(filtered[selectedIndex]);
    }
  }, [filtered, selectedIndex]);

  function handleSelect(r: SearchResult) {
    // Articles with URLs open externally
    if (r.type === 'article' && r.url) {
      window.open(r.url, '_blank', 'noopener,noreferrer');
      setOpen(false);
      setQuery('');
      return;
    }
    // Petitions deep-link to /pulse/petitions/<id>
    if (r.type === 'petition') {
      router.push(`/pulse/petitions/${r.id}`);
    } else if (r.type === 'topic') {
      // Topics filter the petition feed by category
      router.push(`/pulse/petitions?category=${encodeURIComponent(r.subtitle || r.title)}`);
    } else if (r.type === 'organization') {
      // Orgs: search-filter the petition feed
      router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
    } else if (r.type === 'politician') {
      // Politicians: pulse doesn't have a politician detail page yet; surface as search filter
      router.push(`/pulse/petitions?search=${encodeURIComponent(r.title)}`);
    } else if (r.url) {
      window.open(r.url, '_blank', 'noopener,noreferrer');
    }
    setOpen(false);
    setQuery('');
    setResults([]);
  }

  const activeTypes = Object.entries(counts).filter(([, c]) => c > 0);

  return (
    <>
      {/* Trigger button — sits in the Pulse nav between Theme toggle and Auth section */}
      <button
        onClick={() => setOpen(true)}
        title="Search petitions, politicians, orgs (⌘K)"
        aria-label="Open search"
        style={{
          display: 'inline-flex', alignItems: 'center', gap: 8,
          background: 'rgba(255,255,255,0.10)', color: theme.textLight,
          border: '1px solid rgba(255,255,255,0.18)',
          padding: '6px 12px', borderRadius: 8,
          fontFamily: 'system-ui, -apple-system, sans-serif',
          fontSize: 13, fontWeight: 500, cursor: 'pointer',
          transition: 'background-color 150ms',
        }}
        onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.18)')}
        onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.10)')}
      >
        <Search size={14} />
        <span>Search</span>
        <kbd style={{
          fontSize: 10, padding: '1px 6px', borderRadius: 4,
          backgroundColor: 'rgba(255,255,255,0.15)',
          color: theme.textLight,
          fontFamily: 'inherit',
        }}>⌘K</kbd>
      </button>

      {/* Modal overlay */}
      {open && (
        <div
          role="dialog"
          aria-modal="true"
          aria-label="Global search"
          style={{
            position: 'fixed', inset: 0, zIndex: 1000,
            background: 'rgba(27, 67, 50, 0.40)',
            display: 'flex', alignItems: 'flex-start', justifyContent: 'center',
            paddingTop: 80, padding: '80px 16px 16px',
          }}
        >
          <div
            ref={panelRef}
            style={{
              width: '100%', maxWidth: 640, maxHeight: '78vh',
              background: theme.cream, borderRadius: 12,
              boxShadow: '0 24px 60px rgba(0,0,0,0.28)',
              border: `1px solid rgba(0,0,0,0.06)`,
              display: 'flex', flexDirection: 'column',
              overflow: 'hidden',
              fontFamily: 'Georgia, "Times New Roman", serif',
            }}
          >
            {/* Search input */}
            <div style={{
              display: 'flex', alignItems: 'center', gap: 10,
              padding: '14px 18px',
              borderBottom: `1px solid rgba(0,0,0,0.08)`,
              background: theme.cream,
            }}>
              <Search size={18} color={theme.darkGreen} />
              <input
                ref={inputRef}
                type="text"
                value={query}
                onChange={e => setQuery(e.target.value)}
                onKeyDown={handleKeyDown}
                placeholder="Search petitions, politicians, organizations…"
                style={{
                  flex: 1, border: 'none', background: 'transparent', outline: 'none',
                  fontSize: 16, fontFamily: 'Georgia, "Times New Roman", serif',
                  color: '#1a1a1a',
                }}
              />
              <button
                onClick={() => { setOpen(false); setQuery(''); setResults([]); }}
                aria-label="Close search"
                style={{
                  background: 'none', border: 'none', cursor: 'pointer', padding: 4,
                  color: '#6b7280',
                }}
              >
                <X size={18} />
              </button>
            </div>

            {/* Filter chips */}
            {activeTypes.length > 0 && (
              <div style={{
                display: 'flex', gap: 6, padding: '10px 18px', flexWrap: 'wrap',
                borderBottom: `1px solid rgba(0,0,0,0.06)`,
              }}>
                <button
                  onClick={() => setActiveFilter('')}
                  style={{
                    fontSize: 12, padding: '4px 10px', borderRadius: 999,
                    background: activeFilter === '' ? theme.darkGreen : 'transparent',
                    color: activeFilter === '' ? theme.gold : theme.darkGreen,
                    border: `1px solid ${theme.darkGreen}`,
                    cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
                  }}
                >
                  All ({results.length})
                </button>
                {activeTypes.map(([type, count]) => {
                  // counts are keyed plural (petitions, politicians, ...) — map to singular for TYPE_CONFIG.
                  const singularType = type.slice(0, -1) as SearchResult['type'];
                  const cfg = TYPE_CONFIG[singularType];
                  if (!cfg) return null;
                  const isActive = activeFilter === singularType;
                  return (
                    <button
                      key={type}
                      onClick={() => setActiveFilter(isActive ? '' : singularType)}
                      style={{
                        fontSize: 12, padding: '4px 10px', borderRadius: 999,
                        background: isActive ? cfg.color : 'transparent',
                        color: isActive ? '#fff' : cfg.color,
                        border: `1px solid ${cfg.color}`,
                        cursor: 'pointer', fontFamily: 'system-ui, sans-serif', fontWeight: 600,
                      }}
                    >
                      {cfg.label} ({count})
                    </button>
                  );
                })}
              </div>
            )}

            {/* Results list */}
            <div style={{ overflow: 'auto', flex: 1, padding: '6px 0' }}>
              {loading && (
                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
                  Searching…
                </div>
              )}
              {!loading && query.length < 2 && (
                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
                  Type at least 2 characters to search.
                </div>
              )}
              {!loading && query.length >= 2 && filtered.length === 0 && (
                <div style={{ padding: '24px', textAlign: 'center', color: '#6b7280', fontFamily: 'system-ui, sans-serif', fontSize: 14 }}>
                  No results for <strong>&ldquo;{query}&rdquo;</strong>.
                </div>
              )}
              {filtered.map((r, i) => {
                const cfg = TYPE_CONFIG[r.type];
                const Icon = cfg.icon;
                const isSelected = i === selectedIndex;
                return (
                  <button
                    key={`${r.type}-${r.id}-${i}`}
                    onClick={() => handleSelect(r)}
                    onMouseEnter={() => setSelectedIndex(i)}
                    style={{
                      width: '100%', display: 'flex', alignItems: 'center', gap: 12,
                      padding: '10px 18px', textAlign: 'left',
                      background: isSelected ? `${cfg.color}14` : 'transparent',
                      border: 'none', borderLeft: `3px solid ${isSelected ? cfg.color : 'transparent'}`,
                      cursor: 'pointer', fontFamily: 'Georgia, "Times New Roman", serif',
                    }}
                  >
                    <div style={{
                      width: 32, height: 32, borderRadius: 8,
                      background: `${cfg.color}1a`, color: cfg.color,
                      display: 'flex', alignItems: 'center', justifyContent: 'center',
                      flexShrink: 0,
                    }}>
                      <Icon size={16} />
                    </div>
                    <div style={{ flex: 1, minWidth: 0 }}>
                      <div style={{
                        fontSize: 15, color: '#1a1a1a', fontWeight: 600,
                        overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                      }}>
                        {r.title}
                      </div>
                      <div style={{
                        fontSize: 12, color: '#6b7280',
                        fontFamily: 'system-ui, sans-serif',
                        overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
                      }}>
                        {cfg.label}{r.subtitle ? ` · ${r.subtitle}` : ''}{r.meta ? ` · ${r.meta}` : ''}
                      </div>
                    </div>
                    {isSelected && <ArrowRight size={14} color={cfg.color} />}
                  </button>
                );
              })}
            </div>

            {/* Footer hint */}
            <div style={{
              padding: '8px 18px', borderTop: `1px solid rgba(0,0,0,0.06)`,
              fontSize: 11, color: '#6b7280', fontFamily: 'system-ui, sans-serif',
              display: 'flex', justifyContent: 'space-between',
            }}>
              <span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↑</kbd> <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↓</kbd> navigate &nbsp; <kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>↵</kbd> select</span>
              <span><kbd style={{ padding: '1px 5px', border: '1px solid rgba(0,0,0,0.15)', borderRadius: 3 }}>esc</kbd> close</span>
            </div>
          </div>
        </div>
      )}
    </>
  );
}