Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { useState, useRef, useEffect } from 'react' | |
| import { useNavigate, useSearchParams, useLocation } from 'react-router-dom' | |
| import { useQuery, keepPreviousData } from '@tanstack/react-query' | |
| import api from '../lib/api' | |
| import { fetchTopics, type TopicSummary } from '../api/topics' | |
| import { fetchPolicyQuestions, type PolicyQuestionSummary } from '../api/policyQuestions' | |
| import { withSpan } from '../instrumentation' | |
| import { | |
| ArrowLeftIcon, | |
| MagnifyingGlassIcon, | |
| UserIcon, | |
| CalendarIcon, | |
| BuildingOfficeIcon, | |
| HeartIcon, | |
| XMarkIcon, | |
| AdjustmentsHorizontalIcon, | |
| CheckIcon, | |
| MapPinIcon, | |
| ChevronDownIcon, | |
| ChevronUpIcon, | |
| GlobeAltIcon, | |
| VideoCameraIcon, | |
| DocumentTextIcon, | |
| ChatBubbleBottomCenterTextIcon, | |
| ScaleIcon, | |
| BanknotesIcon, | |
| MegaphoneIcon, | |
| DocumentMagnifyingGlassIcon, | |
| QuestionMarkCircleIcon | |
| } from '@heroicons/react/24/outline' | |
| import { formatCurrency, formatCityState, titleCaseCity, expandStateName } from '../utils/formatters' | |
| import MeetingThumbnail from '../components/MeetingThumbnail' | |
| import { StoryCard, CONTESTED_LENS, TRANSCRIPT_LENS, type RenderCard } from '../components/StoryLenses' | |
| import { parseSpeaker } from '../lib/speakers' | |
| import { getLaunchCounty } from '../lib/launchCounties' | |
| import { DocumentViewerProvider, useDocumentViewer } from '../components/DocumentViewerContext' | |
| type SearchResultType = | |
| | 'leader' | |
| | 'person' | |
| | 'meeting' | |
| | 'organization' | |
| | 'cause' | |
| | 'bill' | |
| | 'topic' | |
| | 'decision' | |
| | 'document' | |
| | 'meeting_document' | |
| | 'grant' | |
| | 'grant_opportunity' | |
| | 'question' | |
| interface SearchResult { | |
| type: SearchResultType | |
| result_type?: SearchResultType | |
| title: string | |
| subtitle: string | |
| description: string | |
| // Optional: a result with no stable detail key (e.g. an MDM person with a | |
| // null person_uid) comes back with no url and renders as non-clickable. | |
| url?: string | null | |
| score: number | |
| // Loosely typed grab-bag, but a few keys are contractually meaningful: | |
| // - analysis_pending (meeting results): true ⇒ an UNANALYZED meeting shown | |
| // because its raw transcript is available. Such results have NO | |
| // event_meeting_id, carry an EXTERNAL YouTube `url`, and put a transcript | |
| // snippet (possibly with <mark>…</mark> highlights) in `description`. | |
| // false/absent ⇒ an AI-analyzed meeting that deep-links to /meetings/{id}. | |
| // - video_id (meeting results): YouTube video id for unanalyzed meetings. | |
| metadata: Record<string, any> & { | |
| analysis_pending?: boolean | |
| video_id?: string | |
| event_meeting_id?: number | string | |
| meeting_id?: number | string | |
| // Raw AI speaker slugs for a decision result; parsed client-side into the | |
| // card's "Voices" attribution row. Absent when the result has no testimony. | |
| speakers?: string[] | |
| } | |
| } | |
| interface SearchResponse { | |
| query: string | |
| total_results: number | |
| type_totals?: { | |
| leaders?: number | |
| persons?: number | |
| meetings: number | |
| organizations: number | |
| causes: number | |
| bills: number | |
| topics: number | |
| decisions: number | |
| jurisdictions: number | |
| documents?: number | |
| meeting_documents?: number | |
| grants?: number | |
| grant_opportunities?: number | |
| questions?: number | |
| } | |
| results: { | |
| leaders?: SearchResult[] | |
| persons?: SearchResult[] | |
| meetings: SearchResult[] | |
| organizations: SearchResult[] | |
| causes: SearchResult[] | |
| bills: SearchResult[] | |
| topics: SearchResult[] | |
| decisions: SearchResult[] | |
| jurisdictions?: SearchResult[] | |
| documents?: SearchResult[] | |
| meeting_documents?: SearchResult[] | |
| grants?: SearchResult[] | |
| grant_opportunities?: SearchResult[] | |
| questions?: SearchResult[] | |
| } | |
| pagination: { | |
| page: number | |
| limit: number | |
| offset: number | |
| total_pages: number | |
| has_next: boolean | |
| has_prev: boolean | |
| } | |
| filters: { | |
| state?: string | |
| ntee_code?: string | |
| types: string[] | |
| } | |
| } | |
| // Map legacy request type names to the current /api/search vocabulary so old | |
| // links (?types=contacts / ?types=people / ?types=person) still resolve. | |
| function normalizeTypeAlias(t: string): string { | |
| if (t === 'contacts' || t === 'leader') return 'leaders' | |
| if (t === 'people' || t === 'person') return 'persons' | |
| return t | |
| } | |
| // The result-type vocabulary, shared by the URL whitelist and the Advanced | |
| // Filters "Result types" checkbox group. Previously the pills were a separate | |
| // inline array in the filter bar; that confusing pill row was removed in favor | |
| // of a single source of truth surfaced inside the flyout. | |
| const RESULT_TYPES = [ | |
| { type: 'leaders', label: 'Leaders' }, | |
| // 'persons' (non-government people from mdm_person) is intentionally omitted: | |
| // the backend gates that category off (PERSONS_SEARCH_ENABLED) to stay within | |
| // Neon free-tier storage. Government leaders remain searchable via 'leaders'. | |
| // Re-add this entry if the backend flag is flipped back on. | |
| { type: 'organizations', label: 'Organizations' }, | |
| { type: 'causes', label: 'Causes' }, | |
| { type: 'meetings', label: 'Meetings' }, | |
| // 'documents' = meeting transcripts (full-text searched). Most fluoride/ | |
| // policy-term hits live in the transcript body, not in meeting titles or | |
| // extracted decisions, so this is often the only category that surfaces them. | |
| { type: 'documents', label: 'Videos' }, | |
| // 'meeting_documents' = official meeting PDFs (agenda / minutes / attachments) | |
| // from public.event_meeting_document, full-text searched over extracted PDF | |
| // content where present. Distinct from 'documents' (transcripts) above. | |
| { type: 'meeting_documents', label: 'Meeting Documents' }, | |
| { type: 'bills', label: 'Bills' }, | |
| { type: 'topics', label: 'Topics' }, | |
| { type: 'decisions', label: 'Decisions' }, | |
| { type: 'grants', label: 'Grants' }, | |
| { type: 'grant_opportunities', label: 'Grant Opportunities' }, | |
| // Cross-jurisdiction policy questions (the "big questions" registry). | |
| { type: 'questions', label: 'Questions' }, | |
| ] as const | |
| const ALL_RESULT_TYPE_KEYS = RESULT_TYPES.map((t) => t.type) as readonly string[] | |
| // Default selection when no ?types= is present — every result type is on by default. | |
| const DEFAULT_RESULT_TYPES = [...ALL_RESULT_TYPE_KEYS] | |
| // Tab order for the results view. Each result type that returns hits becomes a | |
| // tab; only the active tab's section renders. Decisions lead because the Money | |
| // Moves / civic-decision lenses are the primary reason users reach search; the | |
| // remaining tabs follow the long-standing section order. `key` matches the | |
| // keys of SearchResponse.results / type_totals so counts resolve directly. | |
| const RESULT_TABS = [ | |
| { key: 'decisions', label: 'Decisions' }, | |
| { key: 'leaders', label: 'Leaders' }, | |
| { key: 'persons', label: 'People' }, | |
| { key: 'organizations', label: 'Organizations' }, | |
| { key: 'causes', label: 'Causes' }, | |
| { key: 'meetings', label: 'Meetings' }, | |
| { key: 'documents', label: 'Videos' }, | |
| { key: 'meeting_documents', label: 'Meeting Documents' }, | |
| { key: 'bills', label: 'Bills' }, | |
| { key: 'topics', label: 'Topics' }, | |
| { key: 'questions', label: 'Questions' }, | |
| { key: 'grants', label: 'Grants' }, | |
| { key: 'grant_opportunities', label: 'Grant Opportunities' }, | |
| { key: 'jurisdictions', label: 'Jurisdictions' }, | |
| ] as const | |
| // Advanced "Cause" filter options — the EveryOrg cause taxonomy. The `id` is the | |
| // cause slug the API's CAUSE_KEYWORDS map / ?cause_id= expects; KEEP IN SYNC with | |
| // api/routes/search.py CAUSE_KEYWORDS. A cause narrows decisions/meetings/topics | |
| // to its keyword set, matched through child decisions then transcript content. | |
| const CAUSE_OPTIONS: { id: string; label: string }[] = [ | |
| { id: 'animals', label: 'Animals' }, | |
| { id: 'arts', label: 'Arts & Culture' }, | |
| { id: 'climate', label: 'Climate' }, | |
| { id: 'disasters', label: 'Disaster Relief' }, | |
| { id: 'education', label: 'Education' }, | |
| { id: 'environment', label: 'Environment' }, | |
| { id: 'foodbanks', label: 'Food & Hunger' }, | |
| { id: 'health', label: 'Health' }, | |
| { id: 'humanitarian', label: 'Humanitarian' }, | |
| { id: 'justice', label: 'Justice & Equity' }, | |
| { id: 'lgbt', label: 'LGBTQ' }, | |
| { id: 'mental-health', label: 'Mental Health' }, | |
| { id: 'religion', label: 'Religion' }, | |
| { id: 'seniors', label: 'Seniors' }, | |
| { id: 'water', label: 'Water' }, | |
| { id: 'women', label: 'Women' }, | |
| { id: 'youth', label: 'Youth' }, | |
| ] | |
| // Stable dot color for a cause/NTEE string (small palette, hashed by key). | |
| const CAUSE_DOT_PALETTE = [ | |
| '#2F5D62', // teal | |
| '#3B82F6', // blue | |
| '#8B5CF6', // violet | |
| '#EC4899', // pink | |
| '#F59E0B', // amber | |
| '#10B981', // emerald | |
| '#EF4444', // red | |
| '#6366F1', // indigo | |
| ] | |
| function causeDotColor(key: string): string { | |
| let hash = 0 | |
| for (let i = 0; i < key.length; i++) { | |
| hash = (hash * 31 + key.charCodeAt(i)) | 0 | |
| } | |
| return CAUSE_DOT_PALETTE[Math.abs(hash) % CAUSE_DOT_PALETTE.length] | |
| } | |
| // Format a 9-digit IRS EIN as "XX-XXXXXXX"; leave anything else untouched. | |
| function formatEin(ein: string): string { | |
| const digits = ein.replace(/\D/g, '') | |
| return digits.length === 9 ? `${digits.slice(0, 2)}-${digits.slice(2)}` : ein | |
| } | |
| // Searchable Topic picker — replaces a long native <select> over the civic-topic | |
| // catalog with a type-to-filter combobox. Closed: shows the selected topic name. | |
| // Open: a search box that narrows the list as you type. Selecting (or clearing) | |
| // calls back with the topic id string the URL/query binds to ('' = All Topics). | |
| function TopicTypeahead({ | |
| topics, | |
| selectedId, | |
| onSelect, | |
| }: { | |
| topics: TopicSummary[] | |
| selectedId: string | |
| onSelect: (id: string) => void | |
| }) { | |
| const [open, setOpen] = useState(false) | |
| const [query, setQuery] = useState('') | |
| const containerRef = useRef<HTMLDivElement>(null) | |
| const selected = topics.find((t) => String(t.topic_id) === selectedId) ?? null | |
| // Close + reset the typed query when clicking outside the combobox. | |
| useEffect(() => { | |
| if (!open) return | |
| const onClickOutside = (e: MouseEvent) => { | |
| if (containerRef.current && !containerRef.current.contains(e.target as Node)) { | |
| setOpen(false) | |
| setQuery('') | |
| } | |
| } | |
| document.addEventListener('mousedown', onClickOutside) | |
| return () => document.removeEventListener('mousedown', onClickOutside) | |
| }, [open]) | |
| const q = query.trim().toLowerCase() | |
| const matches = q | |
| ? topics.filter( | |
| (t) => | |
| t.name.toLowerCase().includes(q) || | |
| (t.keywords ?? []).some((k) => k.toLowerCase().includes(q)), | |
| ) | |
| : topics | |
| const choose = (id: string) => { | |
| onSelect(id) | |
| setOpen(false) | |
| setQuery('') | |
| } | |
| return ( | |
| <div className="relative" ref={containerRef}> | |
| {open ? ( | |
| <div className="relative"> | |
| <MagnifyingGlassIcon className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" /> | |
| <input | |
| type="text" | |
| autoFocus | |
| value={query} | |
| onChange={(e) => setQuery(e.target.value)} | |
| placeholder="Search topics…" | |
| className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-gray-900 focus:outline-none focus:ring-2 focus:ring-primary-500" | |
| /> | |
| </div> | |
| ) : ( | |
| <button | |
| type="button" | |
| onClick={() => setOpen(true)} | |
| className="flex w-full items-center justify-between rounded-lg border border-gray-300 bg-white px-3 py-2 text-left text-gray-900 hover:border-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500" | |
| > | |
| <span className={selected ? 'text-gray-900' : 'text-gray-500'}> | |
| {selected ? selected.name : 'All Topics'} | |
| </span> | |
| {selectedId ? ( | |
| <XMarkIcon | |
| className="h-4 w-4 text-gray-400 hover:text-gray-600" | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| choose('') | |
| }} | |
| /> | |
| ) : ( | |
| <ChevronDownIcon className="h-4 w-4 text-gray-400" /> | |
| )} | |
| </button> | |
| )} | |
| {open && ( | |
| <ul className="absolute z-10 mt-1 max-h-64 w-full overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg"> | |
| <li> | |
| <button | |
| type="button" | |
| onClick={() => choose('')} | |
| className={`flex w-full items-center px-3 py-2 text-left text-sm hover:bg-gray-50 ${ | |
| selectedId === '' ? 'font-semibold text-primary-700' : 'text-gray-700' | |
| }`} | |
| > | |
| All Topics | |
| </button> | |
| </li> | |
| {matches.map((t) => ( | |
| <li key={t.topic_id}> | |
| <button | |
| type="button" | |
| onClick={() => choose(String(t.topic_id))} | |
| className={`flex w-full items-center justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 ${ | |
| String(t.topic_id) === selectedId | |
| ? 'font-semibold text-primary-700' | |
| : 'text-gray-700' | |
| }`} | |
| > | |
| <span className="truncate">{t.name}</span> | |
| {t.transcript_occurrences > 0 && ( | |
| <span className="shrink-0 text-xs text-gray-400"> | |
| {t.transcript_occurrences.toLocaleString()} | |
| </span> | |
| )} | |
| </button> | |
| </li> | |
| ))} | |
| {matches.length === 0 && ( | |
| <li className="px-3 py-2 text-sm text-gray-500">No topics match “{query}”.</li> | |
| )} | |
| </ul> | |
| )} | |
| </div> | |
| ) | |
| } | |
| // Render a ts_headline snippet (matched passage wrapped in literal | |
| // <mark>…</mark> markers) as highlighted React nodes WITHOUT | |
| // dangerouslySetInnerHTML: split on the markers and wrap the odd segments in | |
| // <mark>, so every text segment is React-escaped and the raw transcript body | |
| // can never inject markup. Shared by the transcript tiles + the list card. | |
| function highlightSnippet(text: string) { | |
| return text.split(/<\/?mark>/).map((seg, i) => | |
| i % 2 === 1 ? ( | |
| <mark key={i} className="bg-yellow-200 text-gray-900 rounded px-0.5"> | |
| {seg} | |
| </mark> | |
| ) : ( | |
| <span key={i}>{seg}</span> | |
| ), | |
| ) | |
| } | |
| function UnifiedSearchInner() { | |
| const navigate = useNavigate() | |
| const [searchParams, setSearchParams] = useSearchParams() | |
| const routerLocation = useLocation() | |
| // In-app PDF popout (same react-pdf modal used on the Meeting Documents and | |
| // Decision Detail pages). Available because UnifiedSearch is wrapped in a | |
| // DocumentViewerProvider; meeting-document tiles open the PDF here instead of | |
| // a raw new tab. | |
| const docViewer = useDocumentViewer() | |
| // Whether the user arrived here from the home page (which has no sidebar/nav | |
| // to get back). The Home search navigations tag the route with | |
| // `state.fromHome`; we capture it ONCE on mount because subsequent | |
| // setSearchParams calls (filters/pagination) drop location.state, which would | |
| // otherwise make the back button vanish as soon as the user interacts. | |
| const [cameFromHome] = useState<boolean>( | |
| () => Boolean((routerLocation.state as { fromHome?: boolean } | null)?.fromHome), | |
| ) | |
| // Navigate to a result's detail page, but only when it has a url. Results | |
| // without a stable detail key (url == null) are rendered non-clickable, so | |
| // this is a no-op for them instead of routing to a 404. | |
| // | |
| // Some result types (e.g. opportunities) carry an EXTERNAL url | |
| // (https://www.grants.gov/...) that has no matching App.tsx route. Those must | |
| // open in a new tab instead of being handed to react-router (which would 404). | |
| const isExternalUrl = (url?: string | null): boolean => | |
| !!url && /^https?:\/\//i.test(url) | |
| const openResult = (url?: string | null) => { | |
| if (!url) return | |
| if (isExternalUrl(url)) { | |
| window.open(url, '_blank', 'noopener,noreferrer') | |
| } else { | |
| navigate(url) | |
| } | |
| } | |
| // Initialize state directly from URL params (lazy initializer for performance) | |
| const [query, setQuery] = useState(() => searchParams.get('q') || '') | |
| const [activeQuery, setActiveQuery] = useState(() => searchParams.get('q') || '') | |
| const [selectedEin, setSelectedEin] = useState(() => searchParams.get('ein') || '') | |
| const [selectedTypes, setSelectedTypes] = useState<string[]>(() => { | |
| const typesParam = searchParams.get('types') | |
| if (typesParam) { | |
| const types = typesParam.split(',').map(t => t.trim()).map(normalizeTypeAlias).filter(t => | |
| ALL_RESULT_TYPE_KEYS.includes(t) | |
| ) | |
| return types.length > 0 ? types : [...DEFAULT_RESULT_TYPES] | |
| } | |
| return [...DEFAULT_RESULT_TYPES] | |
| }) | |
| const [selectedState, setSelectedState] = useState(() => searchParams.get('state') || '') | |
| // Meeting-document type filter ('' = all; 'agenda' | 'minutes' | 'attachment'). | |
| // Gated on the 'meeting_documents' result type; URL-synced like selectedState. | |
| const [selectedDocType, setSelectedDocType] = useState(() => searchParams.get('document_type') || '') | |
| const [currentPage, setCurrentPage] = useState(() => parseInt(searchParams.get('page') || '1')) | |
| const [showFilters, setShowFilters] = useState(false) | |
| // Which results tab is active. Seeded from ?tab= for shareable deep links; | |
| // the render-time `effectiveTab` falls back to the first available tab | |
| // (Decisions when present) so a stale/unavailable selection never blanks the | |
| // results. | |
| const [activeTab, setActiveTab] = useState<string>(() => searchParams.get('tab') || '') | |
| const [showSuggestions, setShowSuggestions] = useState(false) | |
| const [sortBy, setSortBy] = useState(() => searchParams.get('sort') || 'relevance') | |
| const [nteeCategory, setNteeCategory] = useState(() => searchParams.get('ntee') || '') | |
| // Advanced "Topic" (named civic-topic catalog) + "Question" (policy-question | |
| // registry) filters. Held as the raw id string the <select> binds to. | |
| const [selectedTopicId, setSelectedTopicId] = useState(() => searchParams.get('topic_id') || '') | |
| const [selectedCauseId, setSelectedCauseId] = useState(() => searchParams.get('cause_id') || '') | |
| const [selectedQuestionId, setSelectedQuestionId] = useState(() => searchParams.get('question_id') || '') | |
| const [includeFullText, setIncludeFullText] = useState(() => searchParams.get('full_text') === 'true') | |
| const [jurisdictionDetails, setJurisdictionDetails] = useState<any[]>(() => { | |
| const detailsParam = searchParams.get('jurisdiction_details') | |
| if (detailsParam) { | |
| try { | |
| return JSON.parse(decodeURIComponent(detailsParam)) | |
| } catch (e) { | |
| return [] | |
| } | |
| } | |
| return [] | |
| }) | |
| // Derived state: Always have state code available from URL OR jurisdiction details | |
| const [effectiveState, setEffectiveState] = useState(() => { | |
| const urlState = searchParams.get('state') | |
| if (urlState) return urlState | |
| // Extract from jurisdiction details if available | |
| const detailsParam = searchParams.get('jurisdiction_details') | |
| if (detailsParam) { | |
| try { | |
| const details = JSON.parse(decodeURIComponent(detailsParam)) | |
| // Find state in jurisdiction hierarchy | |
| for (const j of details) { | |
| if (j.state) return j.state | |
| if (j.type === 'State' || j.type === 'state') { | |
| const stateMap: Record<string, string> = { | |
| 'Massachusetts': 'MA', 'Alabama': 'AL', 'Georgia': 'GA', | |
| 'Washington': 'WA', 'Wisconsin': 'WI', 'California': 'CA', | |
| 'Texas': 'TX', 'New York': 'NY', 'Florida': 'FL' | |
| } | |
| return stateMap[j.name] || j.name | |
| } | |
| } | |
| } catch (e) { | |
| // Ignore parse errors | |
| } | |
| } | |
| return '' | |
| }) | |
| const [expandedJurisdictions, setExpandedJurisdictions] = useState<Set<number>>(new Set()) | |
| const [expandedOrganizations, setExpandedOrganizations] = useState<Set<string>>(new Set()) | |
| // Extract city from jurisdiction details, falling back to a plain ?city= | |
| // URL param so a Home-built scoped link (e.g. ?city=Tuscaloosa&state=AL) | |
| // filters results without needing a jurisdiction_details blob. | |
| const selectedCity = jurisdictionDetails.find(j => | |
| j.type === 'City' || j.type === 'city' || j.type === 'Place' || j.type === 'place' | |
| )?.name || searchParams.get('city') || '' | |
| // "Include surrounding county" scope. When a known launch-city is the active | |
| // city filter, the user can broaden results to its whole county. The backend | |
| // accepts a 5-digit `county_fips` which SUPERSEDES `city` (every city in the | |
| // county). The URL is the single source of truth: `county` param ('0' = off); | |
| // when ABSENT we treat it as ON (default-checked) so a Home link that just | |
| // carries ?city= lands county-scoped. | |
| const launchCounty = getLaunchCounty(selectedCity) | |
| const countyBroadenAvailable = !!launchCounty | |
| // Scope is ON unless the URL explicitly says county=0. | |
| const countyScopeOn = countyBroadenAvailable && searchParams.get('county') !== '0' | |
| // The county FIPS to send when county scope is active (else undefined → send city). | |
| const activeCountyFips = countyScopeOn ? launchCounty!.countyFips : undefined | |
| // The visible scope label for the active place filter — county when broadened, | |
| // else the city. Kept in lockstep with the API call below (MANDATORY rule). | |
| const placeScopeLabel = countyScopeOn ? launchCounty!.countyName : selectedCity | |
| // Toggle the county-broaden checkbox: write/clear ?county= while preserving | |
| // every other param so the scope survives refresh / back-button. | |
| const setCountyScope = (on: boolean) => { | |
| const params = new URLSearchParams(searchParams) | |
| if (on) { | |
| params.delete('county') // absent == default ON | |
| } else { | |
| params.set('county', '0') | |
| } | |
| // Switching scope changes the result set; reset to page 1. | |
| params.delete('page') | |
| setCurrentPage(1) | |
| setSearchParams(params) | |
| } | |
| // Result types count as "active" once the user narrows away from the full set. | |
| const typesNarrowed = selectedTypes.length !== RESULT_TYPES.length | |
| // Badge count on the Filters button — how many advanced filters are engaged. | |
| const activeFilterCount = [ | |
| selectedState, | |
| sortBy !== 'relevance' ? 'sorted' : null, | |
| nteeCategory, | |
| selectedTopicId, | |
| selectedCauseId, | |
| selectedQuestionId, | |
| includeFullText ? 'full text' : null, | |
| typesNarrowed ? 'types' : null, | |
| ].filter(Boolean).length | |
| // Debounced query for autocomplete | |
| const [debouncedQuery, setDebouncedQuery] = useState(query) | |
| const searchInputRef = useRef<HTMLInputElement>(null) | |
| const searchContainerRef = useRef<HTMLFormElement>(null) | |
| // Debounce the query for autocomplete (300ms delay) | |
| useEffect(() => { | |
| const timer = setTimeout(() => { | |
| setDebouncedQuery(query) | |
| }, 300) | |
| return () => clearTimeout(timer) | |
| }, [query]) | |
| // Close suggestions dropdown when clicking outside | |
| useEffect(() => { | |
| const handleClickOutside = (event: MouseEvent) => { | |
| if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) { | |
| setShowSuggestions(false); | |
| } | |
| }; | |
| if (showSuggestions) { | |
| document.addEventListener('mousedown', handleClickOutside); | |
| return () => { | |
| document.removeEventListener('mousedown', handleClickOutside); | |
| }; | |
| } | |
| }, [showSuggestions]); | |
| // Initialize from URL parameters on mount | |
| useEffect(() => { | |
| const queryParam = searchParams.get('q') | |
| const stateParam = searchParams.get('state') | |
| const docTypeParam = searchParams.get('document_type') | |
| const typesParam = searchParams.get('types') | |
| const einParam = searchParams.get('ein') | |
| searchParams.get('page') // Read but don't store | |
| searchParams.get('sort') // Read but don't store | |
| searchParams.get('ntee') // Read but don't store | |
| const topicIdParam = searchParams.get('topic_id') | |
| const causeIdParam = searchParams.get('cause_id') | |
| const questionIdParam = searchParams.get('question_id') | |
| const jurisdictionDetailsParam = searchParams.get('jurisdiction_details') | |
| // Keep the advanced Topic/Cause/Question filters in sync with the URL (back/forward). | |
| setSelectedTopicId(topicIdParam || '') | |
| setSelectedCauseId(causeIdParam || '') | |
| setSelectedQuestionId(questionIdParam || '') | |
| setSelectedDocType(docTypeParam || '') | |
| if (queryParam) { | |
| setQuery(queryParam) | |
| setActiveQuery(queryParam) | |
| } | |
| if (einParam) { | |
| setSelectedEin(einParam) | |
| } | |
| if (stateParam) { | |
| setSelectedState(stateParam) | |
| setEffectiveState(stateParam) | |
| } else if (jurisdictionDetailsParam) { | |
| // Extract state from jurisdiction details | |
| try { | |
| const details = JSON.parse(decodeURIComponent(jurisdictionDetailsParam)) | |
| setJurisdictionDetails(details) | |
| // Find and set effective state | |
| for (const j of details) { | |
| if (j.state) { | |
| setEffectiveState(j.state) | |
| setSelectedState(j.state) | |
| break | |
| } | |
| if (j.type === 'State' || j.type === 'state') { | |
| const stateMap: Record<string, string> = { | |
| 'Massachusetts': 'MA', 'Alabama': 'AL', 'Georgia': 'GA', | |
| 'Washington': 'WA', 'Wisconsin': 'WI', 'California': 'CA', | |
| 'Texas': 'TX', 'New York': 'NY', 'Florida': 'FL' | |
| } | |
| const stateCode = stateMap[j.name] || j.name | |
| setEffectiveState(stateCode) | |
| setSelectedState(stateCode) | |
| break | |
| } | |
| } | |
| } catch (e) { | |
| setJurisdictionDetails([]) | |
| } | |
| } | |
| if (typesParam) { | |
| const types = typesParam.split(',').map(t => t.trim()).map(normalizeTypeAlias).filter(t => | |
| ['leaders', 'persons', 'meetings', 'organizations', 'causes', 'bills', 'topics', 'decisions', 'questions'].includes(t) | |
| ) | |
| if (types.length > 0) { | |
| setSelectedTypes(types) | |
| } | |
| } else if (jurisdictionDetailsParam) { | |
| // Default to showing organizations when landing on a jurisdiction page | |
| setSelectedTypes(['organizations']) | |
| } | |
| }, [searchParams, effectiveState]) | |
| // Preview/autocomplete query for search suggestions (uses debounced query) | |
| const { data: previewResults, isFetching: isFetchingPreview } = useQuery<SearchResponse>({ | |
| queryKey: ['search-preview', debouncedQuery, effectiveState], | |
| queryFn: async () => { | |
| if (!debouncedQuery || debouncedQuery.length < 2) return null | |
| const params: any = { | |
| q: debouncedQuery, | |
| types: 'meetings,decisions,causes,leaders,persons,organizations,bills,topics,questions', | |
| limit: 3 | |
| } | |
| // Use effectiveState - already computed from URL or jurisdiction details | |
| if (effectiveState) { | |
| params.state = effectiveState | |
| } | |
| // Include full text if enabled | |
| if (includeFullText) { | |
| params.full_text = 'true' | |
| } | |
| const response = await api.get('/search/', { params }) | |
| return response.data | |
| }, | |
| enabled: debouncedQuery.length >= 2 && showSuggestions, | |
| staleTime: 5000 // Cache for 5 seconds to avoid excessive requests | |
| }) | |
| // Advanced-filter dropdown options. Topics = the named civic-topic catalog | |
| // (/api/topics, sorted most-discussed first); questions = the policy-question | |
| // registry (/api/policy-question/, featured pinned first). Loaded once and | |
| // cached — small, fixed lists that back the two Advanced dropdowns. | |
| const { data: topicOptions } = useQuery<TopicSummary[]>({ | |
| queryKey: ['advanced-filter-topics'], | |
| queryFn: () => fetchTopics(), | |
| staleTime: 10 * 60 * 1000, | |
| }) | |
| const { data: questionOptions } = useQuery<PolicyQuestionSummary[]>({ | |
| queryKey: ['advanced-filter-questions'], | |
| queryFn: () => fetchPolicyQuestions({ limit: 200 }), | |
| staleTime: 10 * 60 * 1000, | |
| }) | |
| // Whether the current inputs constitute a runnable search (query OR a | |
| // browse-mode filter OR an EIN). Shared by both queries below. | |
| // | |
| // NOTE: we deliberately do NOT enable on `selectedTypes.length > 0` — | |
| // `selectedTypes` defaults to ALL types, so that would auto-run a | |
| // browse-everything search on a bare `/search` (e.g. when the nav "Search" | |
| // link is clicked after visiting Home), surfacing stale-looking results with | |
| // an empty box. Type-only browse modes (Browse Topics/Causes, the Home | |
| // quick-nav tiles, etc.) always arrive with an explicit `?types=` param, so | |
| // we gate that mode on the URL param instead of the always-populated state. | |
| const searchEnabled = | |
| (activeQuery && activeQuery.length >= 2) || | |
| selectedState !== '' || | |
| !!searchParams.get('types') || | |
| selectedEin !== '' || | |
| selectedTopicId !== '' || | |
| selectedCauseId !== '' || | |
| selectedQuestionId !== '' | |
| // Build the shared /search params (everything except per-tab paging). `types` | |
| // and `limit`/`page` are layered on by each caller. | |
| const buildSearchParams = (): Record<string, unknown> => { | |
| const params: Record<string, unknown> = {} | |
| if (activeQuery) params.q = activeQuery | |
| if (selectedEin) params.ein = selectedEin | |
| if (selectedState) params.state = selectedState | |
| // County scope SUPERSEDES city: when broadened to the surrounding county, | |
| // send `county_fips` and DO NOT send `city` (the backend widens to every | |
| // city in the county). Otherwise filter by city as before. | |
| if (activeCountyFips) { | |
| params.county_fips = activeCountyFips | |
| } else if (selectedCity) { | |
| params.city = selectedCity | |
| } | |
| if (sortBy && sortBy !== 'relevance') params.sort = sortBy | |
| if (nteeCategory) params.ntee_code = nteeCategory | |
| if (selectedTopicId) params.topic_id = selectedTopicId | |
| if (selectedCauseId) params.cause_id = selectedCauseId | |
| if (selectedQuestionId) params.question_id = selectedQuestionId | |
| if (selectedDocType) params.document_type = selectedDocType | |
| if (includeFullText) params.full_text = 'true' | |
| return params | |
| } | |
| // Tab counts — ONE lightweight call across all selected types, used only to | |
| // build the results tab bar (which tabs exist + their totals). The query key | |
| // deliberately omits page/active tab, so paging or switching tabs reuses the | |
| // cached counts; only a changed query/filter set refetches. limit=1 keeps the | |
| // per-type fetch minimal — we only consume `type_totals`, not the rows. | |
| const { data: tabCountsData, isLoading: isCountsLoading } = useQuery<SearchResponse>({ | |
| queryKey: ['search-tab-counts', activeQuery, [...selectedTypes].sort().join(','), selectedState, selectedCity, activeCountyFips ?? '', sortBy, nteeCategory, selectedTopicId, selectedCauseId, selectedQuestionId, selectedDocType, selectedEin, includeFullText], | |
| queryFn: async () => { | |
| if (!searchEnabled) return null | |
| const params = { ...buildSearchParams(), types: selectedTypes.join(','), limit: 1, page: 1 } | |
| const response = await api.get('/search/', { params }) | |
| return response.data | |
| }, | |
| enabled: !!searchEnabled, | |
| placeholderData: keepPreviousData, | |
| }) | |
| // --- Result tabs --------------------------------------------------------- | |
| // One tab per selected type that actually returned hits, in RESULT_TABS order | |
| // (Decisions first). Counts come straight from the counts call's per-type | |
| // type_totals — no fabricated numbers. `effectiveTab` is the user's selection | |
| // when it's still available, else the first available tab. | |
| const tabCounts: Record<string, number> = {} | |
| const availableTabs = tabCountsData | |
| ? RESULT_TABS.filter((t) => { | |
| if (!selectedTypes.includes(t.key)) return false | |
| const total = (tabCountsData.type_totals as Record<string, number | undefined> | undefined)?.[t.key] ?? 0 | |
| if (total > 0) { | |
| tabCounts[t.key] = total | |
| return true | |
| } | |
| return false | |
| }) | |
| : [] | |
| const availableTabKeys: string[] = availableTabs.map((t) => t.key) | |
| // Default to the tab with the MOST results so the grand-total headline and the | |
| // tab actually shown line up — otherwise "license plates" lands on Meetings (1) | |
| // while the headline reads 82 (mostly Bills). Ties keep RESULT_TABS order. | |
| const largestTabKey = availableTabs.reduce<string>( | |
| (best, t) => (tabCounts[t.key] > (tabCounts[best] ?? -1) ? t.key : best), | |
| availableTabKeys[0] ?? '', | |
| ) | |
| const effectiveTab = availableTabKeys.includes(activeTab) ? activeTab : largestTabKey | |
| // Active-tab results — fetches ONLY the active tab's type, so `page`/`limit` | |
| // paginate within that single type instead of across a mixed result set. | |
| const { data: searchResults, isLoading: isSearching, error } = useQuery<SearchResponse>({ | |
| queryKey: ['unified-search', activeQuery, effectiveTab, selectedState, selectedCity, activeCountyFips ?? '', currentPage, sortBy, nteeCategory, selectedTopicId, selectedCauseId, selectedQuestionId, selectedDocType, selectedEin, includeFullText], | |
| queryFn: async () => { | |
| if (!effectiveTab) return null | |
| const params = { ...buildSearchParams(), types: effectiveTab, limit: 20, page: currentPage } | |
| // Trace the search data-load. Low-cardinality attributes only — query | |
| // length/presence and enum-ish facets, never the raw query string. | |
| return withSpan( | |
| 'search.fetch', | |
| async () => { | |
| const response = await api.get('/search/', { params }) | |
| return response.data | |
| }, | |
| { | |
| 'search.q.length': activeQuery ? activeQuery.length : 0, | |
| 'search.has_query': !!activeQuery, | |
| 'search.has_state': !!selectedState, | |
| 'search.has_ein': !!selectedEin, | |
| 'search.tab': effectiveTab, | |
| 'search.page': currentPage, | |
| }, | |
| ) | |
| }, | |
| enabled: !!searchEnabled && !!effectiveTab, | |
| // Keep the prior tab/page on screen while the next loads, so paging and | |
| // tab-switching don't blank the list to a full-page spinner. | |
| placeholderData: keepPreviousData, | |
| }) | |
| const handleSearch = (e?: React.FormEvent) => { | |
| e?.preventDefault() | |
| // Allow search with query OR just filters (browse mode) | |
| if (query.trim().length >= 2 || selectedState || selectedTypes.length > 0) { | |
| setActiveQuery(query) | |
| setShowSuggestions(false) | |
| setCurrentPage(1) // Reset to first page on new search | |
| setActiveTab('') // New search → default back to the first tab (Decisions) | |
| // Update URL | |
| const params: any = {} | |
| if (query.trim()) params.q = query | |
| if (selectedState) params.state = selectedState | |
| if (selectedCity) params.city = selectedCity | |
| // Preserve an explicit county-scope OFF (county=0); absent == default ON. | |
| if (selectedCity && searchParams.get('county') === '0') params.county = '0' | |
| if (selectedTypes.length > 0 && selectedTypes.length < 5) { | |
| params.types = selectedTypes.join(',') | |
| } | |
| if (sortBy && sortBy !== 'relevance') params.sort = sortBy | |
| if (nteeCategory) params.ntee = nteeCategory | |
| if (selectedTopicId) params.topic_id = selectedTopicId | |
| if (selectedCauseId) params.cause_id = selectedCauseId | |
| if (selectedQuestionId) params.question_id = selectedQuestionId | |
| if (includeFullText) params.full_text = 'true' | |
| setSearchParams(params) | |
| } | |
| } | |
| const handlePageChange = (newPage: number) => { | |
| setCurrentPage(newPage) | |
| // Update URL | |
| const params: any = {} | |
| if (activeQuery) params.q = activeQuery | |
| if (selectedState) params.state = selectedState | |
| if (selectedCity) params.city = selectedCity | |
| if (selectedCity && searchParams.get('county') === '0') params.county = '0' | |
| if (selectedTypes.length > 0 && selectedTypes.length < 5) { | |
| params.types = selectedTypes.join(',') | |
| } | |
| if (sortBy && sortBy !== 'relevance') params.sort = sortBy | |
| if (nteeCategory) params.ntee = nteeCategory | |
| if (selectedTopicId) params.topic_id = selectedTopicId | |
| if (selectedCauseId) params.cause_id = selectedCauseId | |
| if (selectedQuestionId) params.question_id = selectedQuestionId | |
| if (includeFullText) params.full_text = 'true' | |
| if (effectiveTab) params.tab = effectiveTab | |
| if (newPage > 1) params.page = newPage.toString() | |
| setSearchParams(params) | |
| // Scroll to top | |
| window.scrollTo({ top: 0, behavior: 'smooth' }) | |
| } | |
| // Switch the active results tab: reset to page 1 (pagination is per-tab) and | |
| // persist the choice to ?tab= so the view is shareable/back-button safe. | |
| const selectTab = (key: string) => { | |
| setActiveTab(key) | |
| setCurrentPage(1) | |
| const params = new URLSearchParams(searchParams) | |
| params.set('tab', key) | |
| params.delete('page') | |
| setSearchParams(params) | |
| window.scrollTo({ top: 0, behavior: 'smooth' }) | |
| } | |
| const handleViewAllCategory = (category: string) => { | |
| setActiveQuery(query) | |
| setShowSuggestions(false) | |
| setSelectedTypes([category]) | |
| // Update URL with all current filters | |
| const params: any = { q: query } | |
| if (selectedState) params.state = selectedState | |
| if (selectedCity) params.city = selectedCity | |
| if (selectedCity && searchParams.get('county') === '0') params.county = '0' | |
| params.types = category | |
| if (sortBy && sortBy !== 'relevance') params.sort = sortBy | |
| if (nteeCategory) params.ntee = nteeCategory | |
| if (selectedTopicId) params.topic_id = selectedTopicId | |
| if (selectedCauseId) params.cause_id = selectedCauseId | |
| if (selectedQuestionId) params.question_id = selectedQuestionId | |
| if (includeFullText) params.full_text = 'true' | |
| setSearchParams(params) | |
| } | |
| const toggleType = (type: string) => { | |
| const newTypes = selectedTypes.includes(type) | |
| ? selectedTypes.filter(t => t !== type) | |
| : [...selectedTypes, type] | |
| setSelectedTypes(newTypes) | |
| setCurrentPage(1) | |
| // Update URL with all current filters | |
| const params: any = {} | |
| if (activeQuery) params.q = activeQuery | |
| if (selectedState) params.state = selectedState | |
| if (selectedCity) params.city = selectedCity | |
| if (selectedCity && searchParams.get('county') === '0') params.county = '0' | |
| if (newTypes.length > 0 && newTypes.length < 5) { | |
| params.types = newTypes.join(',') | |
| } | |
| if (sortBy && sortBy !== 'relevance') params.sort = sortBy | |
| if (nteeCategory) params.ntee = nteeCategory | |
| if (selectedTopicId) params.topic_id = selectedTopicId | |
| if (selectedCauseId) params.cause_id = selectedCauseId | |
| if (selectedQuestionId) params.question_id = selectedQuestionId | |
| setSearchParams(params) | |
| } | |
| const toggleJurisdictionExpansion = (index: number) => { | |
| setExpandedJurisdictions(prev => { | |
| const newSet = new Set(prev) | |
| if (newSet.has(index)) { | |
| newSet.delete(index) | |
| } else { | |
| newSet.add(index) | |
| } | |
| return newSet | |
| }) | |
| } | |
| const toggleOrganizationExpansion = (ein: string) => { | |
| setExpandedOrganizations(prev => { | |
| const newSet = new Set(prev) | |
| if (newSet.has(ein)) { | |
| newSet.delete(ein) | |
| } else { | |
| newSet.add(ein) | |
| } | |
| return newSet | |
| }) | |
| } | |
| // Auto-expand organization when EIN is specified in URL | |
| useEffect(() => { | |
| if (selectedEin && searchResults?.results?.organizations) { | |
| // Check if the organization with this EIN is in the results | |
| const hasOrg = searchResults.results.organizations.some( | |
| (org: any) => org.metadata?.ein === selectedEin | |
| ) | |
| if (hasOrg) { | |
| setExpandedOrganizations(new Set([selectedEin])) | |
| } | |
| } | |
| }, [selectedEin, searchResults]) | |
| const getTypeIcon = (type: string) => { | |
| // Handle both singular and plural forms | |
| const normalizedType = type.replace(/s$/, '') | |
| switch (normalizedType) { | |
| case 'person': | |
| case 'leader': | |
| return <UserIcon className="h-5 w-5" /> | |
| case 'meeting': | |
| return <CalendarIcon className="h-5 w-5" /> | |
| case 'organization': | |
| return <BuildingOfficeIcon className="h-5 w-5" /> | |
| case 'cause': | |
| return <HeartIcon className="h-5 w-5" /> | |
| case 'bill': | |
| return <DocumentTextIcon className="h-5 w-5" /> | |
| case 'document': | |
| return <DocumentMagnifyingGlassIcon className="h-5 w-5" /> | |
| case 'topic': | |
| return <ChatBubbleBottomCenterTextIcon className="h-5 w-5" /> | |
| case 'decision': | |
| return <ScaleIcon className="h-5 w-5" /> | |
| case 'question': | |
| return <QuestionMarkCircleIcon className="h-5 w-5" /> | |
| case 'grant': | |
| return <BanknotesIcon className="h-5 w-5" /> | |
| // 'grant_opportunities' normalizes to 'grant_opportunitie' (trailing 's' stripped); | |
| // match both the singular result type and the de-pluralized facet name. | |
| case 'grant_opportunity': | |
| case 'grant_opportunitie': | |
| return <MegaphoneIcon className="h-5 w-5" /> | |
| case 'jurisdiction': | |
| return <MapPinIcon className="h-5 w-5" /> | |
| default: | |
| return null | |
| } | |
| } | |
| const getTypeColor = (type: string) => { | |
| // Handle both singular and plural forms | |
| const normalizedType = type.replace(/s$/, '') | |
| switch (normalizedType) { | |
| case 'leader': | |
| return 'bg-blue-100 text-blue-700 border-blue-200' | |
| case 'person': | |
| return 'bg-sky-100 text-sky-700 border-sky-200' | |
| case 'meeting': | |
| return 'bg-green-100 text-green-700 border-green-200' | |
| case 'organization': | |
| return 'bg-purple-100 text-purple-700 border-purple-200' | |
| case 'cause': | |
| return 'bg-pink-100 text-pink-700 border-pink-200' | |
| case 'bill': | |
| return 'bg-indigo-100 text-indigo-700 border-indigo-200' | |
| case 'document': | |
| return 'bg-cyan-100 text-cyan-700 border-cyan-200' | |
| case 'topic': | |
| return 'bg-teal-100 text-teal-700 border-teal-200' | |
| case 'decision': | |
| return 'bg-amber-100 text-amber-700 border-amber-200' | |
| case 'question': | |
| return 'bg-violet-100 text-violet-700 border-violet-200' | |
| case 'grant': | |
| return 'bg-emerald-100 text-emerald-700 border-emerald-200' | |
| case 'grant_opportunity': | |
| case 'grant_opportunitie': | |
| return 'bg-rose-100 text-rose-700 border-rose-200' | |
| case 'jurisdiction': | |
| return 'bg-orange-100 text-orange-700 border-orange-200' | |
| default: | |
| return 'bg-gray-100 text-gray-700 border-gray-200' | |
| } | |
| } | |
| // Map a decision/meeting search result into the shared Browse-Topics StoryCard | |
| // shape, so search renders the SAME rich card (YouTube preview + stat chips) | |
| // used on Browse Topics. Every value is real metadata — no fabricated stats. | |
| const toStoryCard = (result: SearchResult): RenderCard => { | |
| const md = (result.metadata ?? {}) as Record<string, unknown> | |
| const num = (v: unknown) => (v == null || v === 'null' ? NaN : Number(v)) | |
| const stats: RenderCard['stats'] = [] | |
| const cv = num(md.competing_views_count) | |
| if (Number.isFinite(cv) && cv > 0) { | |
| stats.push({ v: String(cv), l: 'Opposing views', tone: 'amber' }) | |
| } | |
| const vy = num(md.votes_yes) | |
| const vn = num(md.votes_no) | |
| if ((Number.isFinite(vy) || Number.isFinite(vn)) && !(vy === 0 && vn === 0)) { | |
| stats.push({ v: `${Number.isFinite(vy) ? vy : 0}–${Number.isFinite(vn) ? vn : 0}`, l: 'Vote' }) | |
| } | |
| if (stats.length === 0 && md.outcome && String(md.outcome) !== 'null') { | |
| stats.push({ v: String(md.outcome), l: 'Outcome' }) | |
| } | |
| const dateStr = (md.meeting_date ?? md.date ?? md.event_date) as string | undefined | |
| let when = '' | |
| if (dateStr) { | |
| const d = new Date(`${dateStr}T00:00:00`) | |
| if (!Number.isNaN(d.getTime())) when = d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) | |
| } | |
| return { | |
| h: result.title, | |
| stats, | |
| juris: (md.jurisdiction as string) || (md.meeting_name as string) || result.subtitle || '', | |
| when, | |
| url: result.url || undefined, | |
| stateCode: (md.state_code as string) || undefined, | |
| videoId: (md.video_id as string) || (md.meeting_video_id as string) || undefined, | |
| // Matched-passage excerpt so a decision/meeting tile shows *why* it surfaced | |
| // for the active query/filter. The backend wraps the matched terms in literal | |
| // <mark>…</mark> markers (ts_headline) which highlightSnippet renders as | |
| // highlighted, React-escaped segments. In plain browse mode the description | |
| // carries no markers, so it renders as a normal context line. | |
| excerpt: result.description ? highlightSnippet(result.description) : undefined, | |
| // Speaker slugs from the result metadata → parsed Speakers for the compact | |
| // "Voices" attribution row. Absent/non-array ⇒ undefined (never fabricated). | |
| speakers: Array.isArray(md.speakers) | |
| ? (md.speakers as string[]).map(parseSpeaker) | |
| : undefined, | |
| } | |
| } | |
| // 3-column StoryCard grid (matches Browse Topics) for decision/meeting results. | |
| const StoryResultGrid = ({ results }: { results: SearchResult[] }) => ( | |
| <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| {results.map((result, idx) => ( | |
| <StoryCard | |
| key={result.url || idx} | |
| card={toStoryCard(result)} | |
| lens={CONTESTED_LENS} | |
| saved={false} | |
| onToggleSave={() => {}} | |
| onOpen={() => openResult(result.url)} | |
| /> | |
| ))} | |
| </div> | |
| ) | |
| // Map a transcript (document) search result into a StoryCard, carrying the | |
| // highlighted matched passage as the card excerpt so the tile still shows | |
| // *why* it matched (the discussion itself), not just the meeting title. | |
| const toTranscriptCard = (result: SearchResult): RenderCard => { | |
| const md = (result.metadata ?? {}) as Record<string, unknown> | |
| const dateStr = (md.date ?? md.event_date ?? md.meeting_date) as string | undefined | |
| let when = '' | |
| if (dateStr) { | |
| const d = new Date(`${dateStr}T00:00:00`) | |
| if (!Number.isNaN(d.getTime())) when = d.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }) | |
| } | |
| return { | |
| h: result.title, | |
| stats: [], | |
| juris: (md.jurisdiction as string) || result.subtitle || '', | |
| when, | |
| url: result.url || undefined, | |
| stateCode: (md.state_code as string) || undefined, | |
| videoId: (md.video_id as string) || undefined, | |
| excerpt: result.description ? highlightSnippet(result.description) : undefined, | |
| } | |
| } | |
| // 3-column preview-tile grid (matches Browse Topics) for transcript results — | |
| // YouTube still + cyan "Transcript" badge + the highlighted passage. | |
| const TranscriptResultGrid = ({ results }: { results: SearchResult[] }) => ( | |
| <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> | |
| {results.map((result, idx) => ( | |
| <StoryCard | |
| key={result.url || idx} | |
| card={toTranscriptCard(result)} | |
| lens={TRANSCRIPT_LENS} | |
| saved={false} | |
| onToggleSave={() => {}} | |
| onOpen={() => openResult(result.url)} | |
| /> | |
| ))} | |
| </div> | |
| ) | |
| const ResultCard = ({ result }: { result: SearchResult }) => { | |
| const resultType = result.result_type ?? result.type | |
| // Unanalyzed meetings (raw transcripts) are surfaced in the Meetings tab | |
| // alongside AI-analyzed ones. They carry metadata.analysis_pending === true, | |
| // have NO event_meeting_id, an EXTERNAL YouTube `url`, and a transcript | |
| // snippet (with <mark> highlights) in `description`. Analyzed meetings keep | |
| // their internal /meetings/{id} drilldown and rich summary — unchanged. | |
| const isMeetingPending = | |
| resultType === 'meeting' && result.metadata?.analysis_pending === true | |
| // Decision and meeting results may carry a YouTube id for their meeting | |
| // recording; when present we render a compact still in the right rail. | |
| // MeetingThumbnail itself returns nothing on a falsy/404 id, so no-recording | |
| // results render exactly as before. | |
| const meetingVideoId = | |
| (resultType === 'decision' || resultType === 'meeting') | |
| ? (result.metadata?.video_id as string | undefined) | |
| : undefined | |
| // Highlighted matched-passage snippet (shared module helper). | |
| const renderSnippet = highlightSnippet | |
| return ( | |
| <div | |
| className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow" | |
| > | |
| <div className="flex items-start gap-3"> | |
| {/* Leaders / officials show their photo (or a letter avatar) in the | |
| left slot where the generic type icon would otherwise sit. */} | |
| {resultType === 'leader' ? ( | |
| <div className="relative w-12 h-12 flex-shrink-0"> | |
| {result.metadata?.photo_url ? ( | |
| <img | |
| src={result.metadata.photo_url} | |
| alt={result.title} | |
| className="w-12 h-12 rounded-full object-cover bg-gray-100 border border-gray-200" | |
| onError={(e) => { | |
| e.currentTarget.style.display = 'none' | |
| const fallback = e.currentTarget.nextElementSibling as HTMLElement | null | |
| if (fallback) fallback.style.display = 'flex' | |
| }} | |
| /> | |
| ) : null} | |
| <div | |
| className="w-12 h-12 rounded-full items-center justify-center text-white text-lg font-bold" | |
| style={{ | |
| backgroundColor: '#2F5D62', | |
| display: result.metadata?.photo_url ? 'none' : 'flex', | |
| }} | |
| > | |
| {result.title.charAt(0)} | |
| </div> | |
| </div> | |
| ) : ( | |
| <div className={`p-2 rounded-lg border ${getTypeColor(resultType)}`}> | |
| {getTypeIcon(resultType)} | |
| </div> | |
| )} | |
| <div className="flex-1"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1"> | |
| {/* External-url results (e.g. opportunities → grants.gov) render | |
| the title as a real anchor that opens in a new tab, since the | |
| url is not an internal App.tsx route. Internal results keep the | |
| click-to-route behavior via openResult(). */} | |
| {isExternalUrl(result.url) ? ( | |
| <a | |
| href={result.url as string} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| onClick={(e) => e.stopPropagation()} | |
| className="font-semibold text-gray-900 mb-1 inline-flex items-center gap-1 cursor-pointer hover:text-blue-600" | |
| > | |
| {result.title} | |
| <span aria-hidden="true" className="text-xs text-gray-400">↗</span> | |
| </a> | |
| ) : ( | |
| <h3 | |
| onClick={() => openResult(result.url)} | |
| className={`font-semibold text-gray-900 mb-1 ${ | |
| result.url ? 'cursor-pointer hover:text-blue-600' : '' | |
| }`} | |
| > | |
| {result.title} | |
| </h3> | |
| )} | |
| <p className="text-sm text-gray-600 mb-2">{result.subtitle}</p> | |
| </div> | |
| {/* Logo for organizations */} | |
| {result.type === 'organization' && ( | |
| result.metadata?.logo_url ? ( | |
| <img | |
| src={result.metadata.logo_url} | |
| alt={result.title} | |
| className="w-12 h-12 rounded object-contain flex-shrink-0 bg-gray-100 border border-gray-200" | |
| onError={(e) => { | |
| e.currentTarget.style.display = 'none' | |
| const fallback = e.currentTarget.nextElementSibling as HTMLElement | null | |
| if (fallback) fallback.style.display = 'flex' | |
| }} | |
| /> | |
| ) : null | |
| )} | |
| {result.type === 'organization' && ( | |
| <div | |
| className="w-12 h-12 rounded flex items-center justify-center text-white text-lg font-bold flex-shrink-0" | |
| style={{ | |
| backgroundColor: '#52796F', | |
| display: result.metadata?.logo_url ? 'none' : 'flex' | |
| }} | |
| > | |
| {result.title.charAt(0)} | |
| </div> | |
| )} | |
| </div> | |
| {/* Unanalyzed meetings carry a raw transcript snippet in | |
| `description` with the matched passage wrapped in literal | |
| <mark>…</mark> markers (ts_headline) — same convention as the | |
| `document` result type. Reuse renderSnippet so highlights show and | |
| the transcript body stays React-escaped (no | |
| dangerouslySetInnerHTML). Analyzed meetings keep the plain summary. */} | |
| {/* Both transcript snippets (ts_headline over the body) AND analyzed | |
| meeting summaries (ts_headline over the AI summary) carry the matched | |
| passage wrapped in literal <mark>…</mark> markers, so the card shows | |
| WHY the result matched. renderSnippet splits on the markers and React- | |
| escapes every segment (no dangerouslySetInnerHTML); it's a no-op for | |
| descriptions without markers, so all other result types are unchanged. */} | |
| <p className="text-sm text-gray-500 line-clamp-3 mb-2">{renderSnippet(result.description || '')}</p> | |
| {/* Mission statement for organizations */} | |
| {result.type === 'organization' && result.metadata?.mission && ( | |
| <div className="mt-2 mb-2 p-3 bg-blue-50 border-l-4 border-blue-400 rounded"> | |
| <p className="text-sm text-gray-700 italic"> | |
| <span className="font-semibold text-blue-900">Mission: </span> | |
| {result.metadata.mission} | |
| </p> | |
| </div> | |
| )} | |
| {/* Website link for organizations */} | |
| {result.type === 'organization' && result.metadata?.website && ( | |
| <a | |
| href={result.metadata.website} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| onClick={(e) => e.stopPropagation()} | |
| className="text-sm text-blue-600 hover:text-blue-800 hover:underline inline-flex items-center gap-1 mb-2" | |
| > | |
| 🔗 {result.metadata.website} | |
| </a> | |
| )} | |
| {/* Additional metadata for organizations */} | |
| {result.type === 'organization' && result.metadata && ( | |
| <div className="mt-2 flex flex-wrap gap-2 text-xs"> | |
| {result.metadata.ein && ( | |
| <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded"> | |
| EIN: {result.metadata.ein} | |
| </span> | |
| )} | |
| {result.metadata.revenue && result.metadata.revenue > 0 && ( | |
| <span className="px-2 py-1 bg-green-100 text-green-700 rounded"> | |
| 💰 Revenue: {formatCurrency(result.metadata.revenue)} | |
| {result.metadata.tax_year && ` (${result.metadata.tax_year})`} | |
| </span> | |
| )} | |
| {result.metadata.assets && result.metadata.assets > 0 && ( | |
| <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded"> | |
| 📊 Assets: {formatCurrency(result.metadata.assets)} | |
| {result.metadata.tax_year && ` (${result.metadata.tax_year})`} | |
| </span> | |
| )} | |
| {result.metadata.causes && result.metadata.causes.length > 0 && ( | |
| <span className="px-2 py-1 bg-purple-100 text-purple-700 rounded"> | |
| 🏷️ {result.metadata.causes.slice(0, 3).join(', ')} | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| {/* Expandable details for organizations */} | |
| {result.type === 'organization' && result.metadata?.ein && ( | |
| <div className="mt-3"> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| toggleOrganizationExpansion(result.metadata.ein) | |
| }} | |
| className="flex items-center gap-2 text-sm text-gray-600 hover:text-gray-900 font-medium" | |
| > | |
| {expandedOrganizations.has(result.metadata.ein) ? ( | |
| <ChevronUpIcon className="h-4 w-4" /> | |
| ) : ( | |
| <ChevronDownIcon className="h-4 w-4" /> | |
| )} | |
| {expandedOrganizations.has(result.metadata.ein) ? 'Hide' : 'Show'} Details | |
| </button> | |
| {expandedOrganizations.has(result.metadata.ein) && ( | |
| <div className="mt-3 space-y-3 border-t pt-3"> | |
| {/* Financials Section */} | |
| <div> | |
| <h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1"> | |
| 💰 Financial Information | |
| {result.metadata.tax_year && ( | |
| <span className="text-xs text-gray-500 font-normal">(Tax Year {result.metadata.tax_year})</span> | |
| )} | |
| </h4> | |
| {/* Check if ANY financial data exists */} | |
| {!result.metadata.revenue && !result.metadata.assets && !result.metadata.income ? ( | |
| <div className="bg-amber-50 p-4 rounded border border-amber-200 text-sm"> | |
| <p className="text-amber-800 mb-2"> | |
| <span className="font-semibold">📊 Form 990 data not yet available</span> | |
| </p> | |
| <p className="text-amber-700 text-xs"> | |
| Financial information from IRS Form 990 filings is being enriched. | |
| Check back later or visit{' '} | |
| <a | |
| href={`https://projects.propublica.org/nonprofits/organizations/${result.metadata.ein}`} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="underline hover:text-amber-900" | |
| > | |
| ProPublica Nonprofit Explorer | |
| </a> | |
| {' '}for current data. | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm"> | |
| <div className="bg-green-50 p-3 rounded border border-green-200"> | |
| <div className="text-xs text-green-700 font-medium mb-1">Total Revenue</div> | |
| <div className="text-lg font-bold text-green-900"> | |
| {result.metadata.revenue !== null && result.metadata.revenue !== undefined | |
| ? formatCurrency(result.metadata.revenue) | |
| : <span className="text-sm text-gray-500">Pending</span>} | |
| </div> | |
| </div> | |
| <div className="bg-blue-50 p-3 rounded border border-blue-200"> | |
| <div className="text-xs text-blue-700 font-medium mb-1">Total Assets</div> | |
| <div className="text-lg font-bold text-blue-900"> | |
| {result.metadata.assets !== null && result.metadata.assets !== undefined | |
| ? formatCurrency(result.metadata.assets) | |
| : <span className="text-sm text-gray-500">Pending</span>} | |
| </div> | |
| </div> | |
| <div className="bg-purple-50 p-3 rounded border border-purple-200"> | |
| <div className="text-xs text-purple-700 font-medium mb-1">Net Income</div> | |
| <div className="text-lg font-bold text-purple-900"> | |
| {result.metadata.income !== null && result.metadata.income !== undefined | |
| ? formatCurrency(result.metadata.income) | |
| : <span className="text-sm text-gray-500">Pending</span>} | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Board Members Section - Placeholder */} | |
| <div> | |
| <h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1"> | |
| 👥 Board Members | |
| </h4> | |
| <div className="bg-gray-50 p-3 rounded border border-gray-200 text-sm text-gray-600"> | |
| Board member information coming soon | |
| </div> | |
| </div> | |
| {/* Grants Section - Placeholder */} | |
| <div> | |
| <h4 className="text-sm font-semibold text-gray-700 mb-2 flex items-center gap-1"> | |
| 📜 Recent Grants | |
| </h4> | |
| <div className="bg-gray-50 p-3 rounded border border-gray-200 text-sm text-gray-600"> | |
| Grant information coming soon | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| {/* Grant-specific metadata badges (amount, grantor location, tax year) */} | |
| {resultType === 'grant' && result.metadata && ( | |
| <div className="mt-2 flex flex-wrap gap-2 text-xs"> | |
| {result.metadata.amount !== null && result.metadata.amount !== undefined && ( | |
| <span className="px-2 py-1 bg-emerald-100 text-emerald-700 rounded"> | |
| 💰 {formatCurrency(result.metadata.amount)} | |
| </span> | |
| )} | |
| {(result.metadata.city || result.metadata.state_code) && ( | |
| <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded"> | |
| 📍 {formatCityState(result.metadata.city, result.metadata.state_code)} | |
| </span> | |
| )} | |
| {result.metadata.tax_year && ( | |
| <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded"> | |
| 📅 Tax Year {result.metadata.tax_year} | |
| </span> | |
| )} | |
| {result.metadata.source_url && ( | |
| <a | |
| href={result.metadata.source_url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| onClick={(e) => e.stopPropagation()} | |
| className="px-2 py-1 bg-gray-100 text-blue-600 hover:text-blue-800 hover:underline rounded inline-flex items-center gap-1" | |
| > | |
| 🔗 Source | |
| </a> | |
| )} | |
| </div> | |
| )} | |
| {/* Opportunity-specific metadata badges (federal grant opportunities | |
| from Grants.gov — distinct from historical 990 grantmaking above). */} | |
| {resultType === 'grant_opportunity' && result.metadata && ( | |
| <div className="mt-2 flex flex-wrap gap-2 text-xs"> | |
| {result.metadata.opp_status && ( | |
| <span | |
| className={`px-2 py-1 rounded ${ | |
| result.metadata.is_open | |
| ? 'bg-green-100 text-green-700' | |
| : 'bg-gray-100 text-gray-700' | |
| }`} | |
| > | |
| {result.metadata.is_open ? '🟢' : '⚪'} {String(result.metadata.opp_status).charAt(0).toUpperCase() + String(result.metadata.opp_status).slice(1)} | |
| </span> | |
| )} | |
| {result.metadata.agency_name && ( | |
| <span className="px-2 py-1 bg-rose-100 text-rose-700 rounded"> | |
| 🏛️ {result.metadata.agency_name} | |
| </span> | |
| )} | |
| {result.metadata.opportunity_number && ( | |
| <span className="px-2 py-1 bg-gray-100 text-gray-700 rounded"> | |
| #{result.metadata.opportunity_number} | |
| </span> | |
| )} | |
| {result.metadata.close_date && ( | |
| <span className="px-2 py-1 bg-amber-100 text-amber-700 rounded"> | |
| 📅 Closes {result.metadata.close_date} | |
| </span> | |
| )} | |
| {result.metadata.aln && ( | |
| <span className="px-2 py-1 bg-blue-100 text-blue-700 rounded"> | |
| ALN {result.metadata.aln} | |
| </span> | |
| )} | |
| {result.metadata.external_url && ( | |
| <a | |
| href={result.metadata.external_url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| onClick={(e) => e.stopPropagation()} | |
| className="px-2 py-1 bg-gray-100 text-blue-600 hover:text-blue-800 hover:underline rounded inline-flex items-center gap-1" | |
| > | |
| 🔗 View on Grants.gov | |
| </a> | |
| )} | |
| </div> | |
| )} | |
| {/* Type badge (+ "Analysis pending" for unanalyzed meetings) */} | |
| <div className="mt-2 flex flex-wrap items-center gap-2"> | |
| <span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getTypeColor(resultType)}`}> | |
| {getTypeIcon(resultType)} | |
| {resultType.charAt(0).toUpperCase() + resultType.slice(1)} | |
| </span> | |
| {/* Muted amber pill: this meeting hasn't been AI-analyzed yet but is | |
| shown because its transcript is available. Mirrors the existing | |
| rounded-full px-2 py-1 text-xs badge style used across results. */} | |
| {isMeetingPending && ( | |
| <span | |
| className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-amber-50 text-amber-700 border border-amber-200" | |
| title="This meeting hasn't been analyzed yet — shown because its transcript is available." | |
| > | |
| <DocumentMagnifyingGlassIcon className="h-3.5 w-3.5" /> | |
| Analysis pending | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| {/* Meeting-video still — decision/meeting results with a recording. | |
| Fixed-width 16:9 in the right rail of the row; rounded + clipped. | |
| Renders nothing when there's no recording (MeetingThumbnail → null), | |
| so non-recording results keep their current layout. */} | |
| {meetingVideoId && ( | |
| <MeetingThumbnail | |
| videoId={meetingVideoId} | |
| alt={result.title} | |
| className="hidden w-40 shrink-0 self-start rounded-md sm:block" | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| return ( | |
| <div className="min-h-screen bg-gray-50"> | |
| <div className="max-w-6xl mx-auto px-6 pb-6"> | |
| {/* Search Header */} | |
| <div className="bg-white rounded-lg shadow-sm p-6 mb-6"> | |
| {cameFromHome && ( | |
| <button | |
| type="button" | |
| onClick={() => navigate('/')} | |
| className="mb-3 inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 transition-colors hover:text-gray-900" | |
| > | |
| <ArrowLeftIcon className="h-4 w-4" /> | |
| Back to home | |
| </button> | |
| )} | |
| <h1 className="text-3xl font-bold text-gray-900 mb-4">Search</h1> | |
| {/* Search Bar + Filters on the same row */} | |
| <div className="flex items-stretch gap-3"> | |
| <form onSubmit={handleSearch} className="relative flex-1" ref={searchContainerRef}> | |
| <div className="flex items-stretch gap-3"> | |
| <div className="relative flex-1"> | |
| <input | |
| ref={searchInputRef} | |
| type="text" | |
| value={query} | |
| onChange={(e) => { | |
| setQuery(e.target.value) | |
| setShowSuggestions(true) | |
| }} | |
| onFocus={() => setShowSuggestions(true)} | |
| placeholder="Search people, meetings, organizations, bills, topics, decisions, causes..." | |
| className="w-full pl-4 pr-12 py-3 border-2 border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent text-lg text-gray-900" | |
| /> | |
| {query && ( | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setQuery('') | |
| setActiveQuery('') | |
| searchInputRef.current?.focus() | |
| }} | |
| className="absolute right-4 top-3.5 text-gray-400 hover:text-gray-600" | |
| > | |
| <XMarkIcon className="h-6 w-6" /> | |
| </button> | |
| )} | |
| </div> | |
| <button | |
| type="submit" | |
| aria-label="Search" | |
| className="flex shrink-0 items-center justify-center rounded-lg bg-primary-600 px-5 text-white transition-colors hover:bg-primary-700" | |
| > | |
| <MagnifyingGlassIcon className="h-6 w-6" /> | |
| </button> | |
| </div> | |
| {/* Rich Preview Dropdown with Grouped Results */} | |
| {showSuggestions && query.length >= 2 && ( | |
| <div className="absolute z-10 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-xl max-h-96 overflow-y-auto"> | |
| {/* Loading State */} | |
| {(isFetchingPreview || query !== debouncedQuery) && ( | |
| <div className="px-4 py-8 text-center"> | |
| <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600 mb-2"></div> | |
| <p className="text-sm text-gray-600">Searching...</p> | |
| </div> | |
| )} | |
| {/* Results */} | |
| {!isFetchingPreview && query === debouncedQuery && previewResults && previewResults.total_results > 0 && ( | |
| <> | |
| {/* Meetings Section */} | |
| {previewResults.results.meetings && previewResults.results.meetings.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <CalendarIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Meetings</span> | |
| </div> | |
| <button | |
| onClick={() => handleViewAllCategory('meetings')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| </div> | |
| {previewResults.results.meetings.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <CalendarIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Decisions Section */} | |
| {previewResults.results.decisions && previewResults.results.decisions.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <ScaleIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Decisions</span> | |
| </div> | |
| <button | |
| onClick={() => handleViewAllCategory('decisions')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| </div> | |
| {previewResults.results.decisions.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <ScaleIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Causes Section */} | |
| {previewResults.results.causes.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <HeartIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Causes</span> | |
| </div> | |
| {previewResults.results.causes.length > 0 && ( | |
| <button | |
| onClick={() => handleViewAllCategory('causes')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| )} | |
| </div> | |
| {previewResults.results.causes.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <HeartIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Leaders Section (government officials) */} | |
| {(previewResults.results.leaders ?? []).length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <UserIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Leaders</span> | |
| </div> | |
| <button | |
| onClick={() => handleViewAllCategory('leaders')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| </div> | |
| {(previewResults.results.leaders ?? []).slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <UserIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate"> | |
| {[result.metadata?.title, result.metadata?.jurisdiction] | |
| .filter(Boolean) | |
| .join(' – ') || result.subtitle} | |
| </div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* People Section (real people incl. residents/homeowners) */} | |
| {(previewResults.results.persons ?? []).length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <UserIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">People</span> | |
| </div> | |
| <button | |
| onClick={() => handleViewAllCategory('persons')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| </div> | |
| {(previewResults.results.persons ?? []).slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <UserIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Organizations Section */} | |
| {previewResults.results.organizations.length > 0 && ( | |
| <div> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <BuildingOfficeIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Organizations</span> | |
| </div> | |
| {previewResults.results.organizations.length > 0 && ( | |
| <button | |
| onClick={() => handleViewAllCategory('organizations')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| )} | |
| </div> | |
| {previewResults.results.organizations.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors last:rounded-b-lg" | |
| > | |
| {/* Logo with fallback */} | |
| {result.metadata?.logo_url ? ( | |
| <img | |
| src={result.metadata.logo_url} | |
| alt={result.title} | |
| className="h-10 w-10 rounded object-contain flex-shrink-0 bg-gray-100 border border-gray-200" | |
| onError={(e) => { | |
| e.currentTarget.style.display = 'none' | |
| const fallback = e.currentTarget.nextElementSibling as HTMLElement | null | |
| if (fallback) fallback.style.display = 'flex' | |
| }} | |
| /> | |
| ) : null} | |
| <div | |
| className="h-10 w-10 rounded flex items-center justify-center text-white text-sm font-bold flex-shrink-0" | |
| style={{ | |
| backgroundColor: '#52796F', | |
| display: result.metadata?.logo_url ? 'none' : 'flex' | |
| }} | |
| > | |
| {result.title.charAt(0)} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Bills Section */} | |
| {previewResults.results.bills && previewResults.results.bills.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <DocumentTextIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Bills</span> | |
| </div> | |
| {previewResults.results.bills.length > 0 && ( | |
| <button | |
| onClick={() => handleViewAllCategory('bills')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| )} | |
| </div> | |
| {previewResults.results.bills.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <DocumentTextIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Topics Section */} | |
| {previewResults.results.topics && previewResults.results.topics.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <ChatBubbleBottomCenterTextIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Topics</span> | |
| </div> | |
| {previewResults.results.topics.length > 0 && ( | |
| <button | |
| onClick={() => handleViewAllCategory('topics')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| )} | |
| </div> | |
| {previewResults.results.topics.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <ChatBubbleBottomCenterTextIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Questions Section (cross-jurisdiction policy questions) */} | |
| {previewResults.results.questions && previewResults.results.questions.length > 0 && ( | |
| <div className="border-b border-gray-200"> | |
| <div className="px-4 py-2 bg-gray-50 flex items-center justify-between"> | |
| <div className="flex items-center gap-2"> | |
| <QuestionMarkCircleIcon className="h-4 w-4 text-gray-500" /> | |
| <span className="text-xs font-semibold text-gray-700 uppercase">Questions</span> | |
| </div> | |
| <button | |
| onClick={() => handleViewAllCategory('questions')} | |
| className="text-xs text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| View All | |
| </button> | |
| </div> | |
| {previewResults.results.questions.slice(0, 3).map((result, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => openResult(result.url)} | |
| className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors" | |
| > | |
| <QuestionMarkCircleIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" /> | |
| <div className="flex-1 min-w-0"> | |
| <div className="font-medium text-gray-900 truncate">{result.title}</div> | |
| <div className="text-sm text-gray-600 truncate">{result.subtitle}</div> | |
| </div> | |
| </button> | |
| ))} | |
| </div> | |
| )} | |
| {/* Footer with total results */} | |
| <div className="px-4 py-2 bg-gray-50 text-center border-t border-gray-200"> | |
| <button | |
| onClick={() => handleSearch()} | |
| className="text-sm text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| See all {previewResults.total_results} results → | |
| </button> | |
| </div> | |
| </> | |
| )} | |
| {/* No Results State */} | |
| {!isFetchingPreview && query === debouncedQuery && previewResults && previewResults.total_results === 0 && ( | |
| <div className="px-4 py-8 text-center"> | |
| <p className="text-gray-600">No results found for "{query}"</p> | |
| <p className="text-sm text-gray-500 mt-1">Try a different search term</p> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </form> | |
| {/* Filter Bar — a single entry point into Advanced Filters, kept on the | |
| same row as the search box. The old type-selection pill row lived | |
| here; it was confusing, so result-type selection now lives inside | |
| the flyout alongside the other filters. */} | |
| <button | |
| onClick={() => setShowFilters(!showFilters)} | |
| className={`flex-shrink-0 flex items-center gap-2 px-3 sm:px-4 py-3 rounded-lg border-2 transition-colors text-sm ${ | |
| showFilters | |
| ? 'border-primary-500 bg-primary-50 text-primary-700' | |
| : 'border-gray-300 text-gray-700 hover:border-gray-400 hover:bg-gray-50' | |
| }`} | |
| > | |
| <AdjustmentsHorizontalIcon className="h-4 w-4 sm:h-5 sm:w-5" /> | |
| <span>Filters</span> | |
| {activeFilterCount > 0 && ( | |
| <span className="ml-1 px-2 py-0.5 bg-primary-600 text-white text-xs rounded-full"> | |
| {activeFilterCount} | |
| </span> | |
| )} | |
| </button> | |
| </div> | |
| {/* Active Filters Display */} | |
| {(selectedState || selectedCity || sortBy !== 'relevance' || nteeCategory || selectedTopicId || selectedQuestionId || jurisdictionDetails.length > 0 || includeFullText) && ( | |
| <div className="mt-3 flex items-center gap-2 flex-wrap"> | |
| <span className="text-sm text-gray-600">Active filters:</span> | |
| {selectedState && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"> | |
| State: {selectedState} | |
| <button | |
| onClick={() => { | |
| setSelectedState('') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-blue-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {/* Place chip — reflects the ACTIVE scope: the county when broadened | |
| ("Include surrounding county" on), else the city. Label and data | |
| move in lockstep (MANDATORY scope-label rule). */} | |
| {selectedCity && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"> | |
| {countyScopeOn | |
| ? `County: ${placeScopeLabel}` | |
| : `City: ${selectedCity}`} | |
| <button | |
| onClick={() => { | |
| const params = new URLSearchParams(window.location.search) | |
| params.delete('city') | |
| params.delete('county') | |
| setSearchParams(params) | |
| }} | |
| className="hover:bg-blue-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {/* Include-surrounding-county toggle — only when the active city is a | |
| known launch city with a distinct county (SF excluded). Default | |
| ON via the absent `county` param. */} | |
| {countyBroadenAvailable && launchCounty && ( | |
| <label className="inline-flex cursor-pointer items-center gap-1.5 rounded-full border border-teal-200 bg-teal-50 px-3 py-1 text-sm text-teal-800"> | |
| <input | |
| type="checkbox" | |
| checked={countyScopeOn} | |
| onChange={(e) => setCountyScope(e.target.checked)} | |
| className="h-3.5 w-3.5 rounded border-teal-300 text-teal-600 focus:ring-teal-500" | |
| /> | |
| Include all of {launchCounty.countyName} | |
| </label> | |
| )} | |
| {jurisdictionDetails.length > 0 && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-teal-100 text-teal-800 rounded-full text-sm"> | |
| <MapPinIcon className="h-3 w-3" /> | |
| {jurisdictionDetails.length} Jurisdictions | |
| <button | |
| onClick={() => { | |
| setJurisdictionDetails([]) | |
| const params = new URLSearchParams(window.location.search) | |
| params.delete('jurisdiction_details') | |
| setSearchParams(params) | |
| }} | |
| className="hover:bg-teal-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {sortBy !== 'relevance' && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-purple-100 text-purple-800 rounded-full text-sm"> | |
| Sort: { | |
| sortBy === 'name-asc' ? 'Name A-Z' : | |
| sortBy === 'name-desc' ? 'Name Z-A' : | |
| sortBy === 'revenue-desc' ? 'Revenue ↓' : | |
| sortBy === 'revenue-asc' ? 'Revenue ↑' : | |
| sortBy === 'assets-desc' ? 'Assets ↓' : | |
| sortBy === 'assets-asc' ? 'Assets ↑' : sortBy | |
| } | |
| <button | |
| onClick={() => { | |
| setSortBy('relevance') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-purple-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {nteeCategory && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-green-100 text-green-800 rounded-full text-sm"> | |
| Category: {nteeCategory} | |
| <button | |
| onClick={() => { | |
| setNteeCategory('') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-green-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {selectedTopicId && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-indigo-100 text-indigo-800 rounded-full text-sm"> | |
| Topic: {(topicOptions ?? []).find((t) => String(t.topic_id) === selectedTopicId)?.name ?? selectedTopicId} | |
| <button | |
| onClick={() => { | |
| setSelectedTopicId('') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-indigo-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {selectedCauseId && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-emerald-100 text-emerald-800 rounded-full text-sm"> | |
| Cause: {CAUSE_OPTIONS.find((c) => c.id === selectedCauseId)?.label ?? selectedCauseId} | |
| <button | |
| onClick={() => { | |
| setSelectedCauseId('') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-emerald-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {selectedQuestionId && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-rose-100 text-rose-800 rounded-full text-sm max-w-xs"> | |
| <span className="truncate"> | |
| Question: {(questionOptions ?? []).find((qn) => qn.question_id === selectedQuestionId)?.canonical_text ?? selectedQuestionId} | |
| </span> | |
| <button | |
| onClick={() => { | |
| setSelectedQuestionId('') | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-rose-200 rounded-full p-0.5 shrink-0" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| {includeFullText && ( | |
| <span className="inline-flex items-center gap-1 px-3 py-1 bg-amber-100 text-amber-800 rounded-full text-sm"> | |
| <DocumentTextIcon className="h-3 w-3" /> | |
| Full text | |
| <button | |
| onClick={() => { | |
| setIncludeFullText(false) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="hover:bg-amber-200 rounded-full p-0.5" | |
| > | |
| <XMarkIcon className="h-3 w-3" /> | |
| </button> | |
| </span> | |
| )} | |
| </div> | |
| )} | |
| {/* Advanced Filters Flyout */} | |
| {showFilters && ( | |
| <> | |
| {/* Backdrop */} | |
| <div | |
| className="fixed inset-0 bg-black bg-opacity-50 z-40" | |
| onClick={() => setShowFilters(false)} | |
| /> | |
| {/* Flyout Sidebar */} | |
| <div className="fixed right-0 top-0 h-full w-full md:w-96 bg-white shadow-2xl z-50 overflow-y-auto"> | |
| <div className="p-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-6"> | |
| <h3 className="text-xl font-bold text-gray-900">Advanced Filters</h3> | |
| <button | |
| onClick={() => setShowFilters(false)} | |
| className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-md transition-colors" | |
| > | |
| <XMarkIcon className="h-6 w-6" /> | |
| </button> | |
| </div> | |
| {/* Filters */} | |
| <div className="space-y-6"> | |
| {/* Result Types — replaces the old pill row. Pick which kinds of | |
| results to include. */} | |
| <div> | |
| <div className="flex items-center justify-between mb-2"> | |
| <label className="block text-sm font-medium text-gray-700"> | |
| Result types | |
| </label> | |
| <div className="flex items-center gap-3 text-xs"> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setSelectedTypes([...ALL_RESULT_TYPE_KEYS]) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="text-primary-600 hover:text-primary-700 font-medium" | |
| > | |
| Select all | |
| </button> | |
| <button | |
| type="button" | |
| onClick={() => { | |
| setSelectedTypes([]) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="text-gray-500 hover:text-gray-700 font-medium" | |
| > | |
| Clear | |
| </button> | |
| </div> | |
| </div> | |
| <div className="space-y-1.5"> | |
| {RESULT_TYPES.map(({ type, label }) => ( | |
| <label | |
| key={type} | |
| className="flex items-center gap-2 cursor-pointer group py-1" | |
| > | |
| <input | |
| type="checkbox" | |
| checked={selectedTypes.includes(type)} | |
| onChange={() => toggleType(type)} | |
| className="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-2 focus:ring-primary-500 cursor-pointer" | |
| /> | |
| <span className="text-gray-500 group-hover:text-gray-700"> | |
| {getTypeIcon(type)} | |
| </span> | |
| <span className="text-sm text-gray-700 group-hover:text-gray-900"> | |
| {label} | |
| </span> | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| {/* State Filter */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| State | |
| </label> | |
| <select | |
| value={selectedState} | |
| onChange={(e) => { | |
| setSelectedState(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| > | |
| <option value="" className="text-gray-900">All States</option> | |
| <option value="AL" className="text-gray-900">Alabama</option> | |
| <option value="GA" className="text-gray-900">Georgia</option> | |
| <option value="MA" className="text-gray-900">Massachusetts</option> | |
| <option value="WA" className="text-gray-900">Washington</option> | |
| <option value="WI" className="text-gray-900">Wisconsin</option> | |
| </select> | |
| </div> | |
| {/* Sort By */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Sort By | |
| </label> | |
| <select | |
| value={sortBy} | |
| onChange={(e) => { | |
| setSortBy(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| > | |
| <option value="relevance" className="text-gray-900">Relevance</option> | |
| <optgroup label="Date (decisions)"> | |
| <option value="date_desc" className="text-gray-900">Date (Newest first)</option> | |
| <option value="date_asc" className="text-gray-900">Date (Oldest first)</option> | |
| <option value="theme" className="text-gray-900">Theme (A-Z)</option> | |
| <option value="outcome" className="text-gray-900">Outcome (A-Z)</option> | |
| </optgroup> | |
| <optgroup label="Organizations"> | |
| <option value="name-asc" className="text-gray-900">Name (A-Z)</option> | |
| <option value="name-desc" className="text-gray-900">Name (Z-A)</option> | |
| <option value="revenue-desc" className="text-gray-900">Revenue (High to Low)</option> | |
| <option value="revenue-asc" className="text-gray-900">Revenue (Low to High)</option> | |
| <option value="assets-desc" className="text-gray-900">Assets (High to Low)</option> | |
| <option value="assets-asc" className="text-gray-900">Assets (Low to High)</option> | |
| </optgroup> | |
| </select> | |
| </div> | |
| {/* Cause / NTEE category (for organizations) */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Cause | |
| </label> | |
| <select | |
| value={nteeCategory} | |
| onChange={(e) => { | |
| setNteeCategory(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| disabled={!selectedTypes.includes('organizations')} | |
| > | |
| <option value="" className="text-gray-900">All Categories</option> | |
| <option value="A" className="text-gray-900">Arts & Culture</option> | |
| <option value="B" className="text-gray-900">Education</option> | |
| <option value="C" className="text-gray-900">Environment</option> | |
| <option value="D" className="text-gray-900">Animal-Related</option> | |
| <option value="E" className="text-gray-900">Health</option> | |
| <option value="F" className="text-gray-900">Mental Health</option> | |
| <option value="G" className="text-gray-900">Diseases</option> | |
| <option value="H" className="text-gray-900">Medical Research</option> | |
| <option value="I" className="text-gray-900">Crime & Legal</option> | |
| <option value="J" className="text-gray-900">Employment</option> | |
| <option value="K" className="text-gray-900">Food & Agriculture</option> | |
| <option value="L" className="text-gray-900">Housing</option> | |
| <option value="M" className="text-gray-900">Public Safety</option> | |
| <option value="N" className="text-gray-900">Recreation & Sports</option> | |
| <option value="O" className="text-gray-900">Youth Development</option> | |
| <option value="P" className="text-gray-900">Human Services</option> | |
| <option value="Q" className="text-gray-900">International</option> | |
| <option value="R" className="text-gray-900">Civil Rights</option> | |
| <option value="S" className="text-gray-900">Community</option> | |
| <option value="T" className="text-gray-900">Philanthropy</option> | |
| <option value="U" className="text-gray-900">Science</option> | |
| <option value="V" className="text-gray-900">Social Science</option> | |
| <option value="W" className="text-gray-900">Public Affairs</option> | |
| <option value="X" className="text-gray-900">Religion</option> | |
| <option value="Y" className="text-gray-900">Mutual Benefit</option> | |
| </select> | |
| </div> | |
| {/* Document type (for Meeting Documents). Narrows the official | |
| meeting-document results to agendas, minutes, or attachments. | |
| Gated on the 'meeting_documents' result type being selected. */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Document type | |
| </label> | |
| <select | |
| value={selectedDocType} | |
| onChange={(e) => { | |
| setSelectedDocType(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| disabled={!selectedTypes.includes('meeting_documents')} | |
| > | |
| <option value="" className="text-gray-900">All types</option> | |
| <option value="agenda" className="text-gray-900">Agenda</option> | |
| <option value="minutes" className="text-gray-900">Notes (minutes)</option> | |
| <option value="attachment" className="text-gray-900">Attachments</option> | |
| </select> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| Filters Meeting Documents by kind. | |
| </p> | |
| </div> | |
| {/* Topic — named civic-topic catalog (/api/topics). Narrows | |
| decisions / meetings / topics to the chosen topic's keyword set. */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Topic | |
| </label> | |
| <TopicTypeahead | |
| topics={topicOptions ?? []} | |
| selectedId={selectedTopicId} | |
| onSelect={(id) => { | |
| setSelectedTopicId(id) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| /> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| Limits decisions, meetings & topics to this civic topic. | |
| </p> | |
| </div> | |
| {/* Cause — EveryOrg cause taxonomy. Narrows decisions/meetings/ | |
| topics to the cause's keyword set, matched through child | |
| decisions then transcript content (cause -> decision -> meeting, | |
| transcript fallback). ?cause_id= the slug. */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Cause | |
| </label> | |
| <select | |
| value={selectedCauseId} | |
| onChange={(e) => { | |
| setSelectedCauseId(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| > | |
| <option value="" className="text-gray-900">All Causes</option> | |
| {CAUSE_OPTIONS.map((c) => ( | |
| <option key={c.id} value={c.id} className="text-gray-900"> | |
| {c.label} | |
| </option> | |
| ))} | |
| </select> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| Limits decisions, meetings & topics to this cause. | |
| </p> | |
| </div> | |
| {/* Question — policy-question registry (/api/policy-question/). | |
| Narrows decisions to those that instantiate the chosen question. */} | |
| <div> | |
| <label className="block text-sm font-medium text-gray-700 mb-2"> | |
| Question | |
| </label> | |
| <select | |
| value={selectedQuestionId} | |
| onChange={(e) => { | |
| setSelectedQuestionId(e.target.value) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 text-gray-900 bg-white" | |
| > | |
| <option value="" className="text-gray-900">All Questions</option> | |
| {(questionOptions ?? []).map((qn) => ( | |
| <option key={qn.question_id} value={qn.question_id} className="text-gray-900"> | |
| {qn.canonical_text && qn.canonical_text.length > 80 | |
| ? `${qn.canonical_text.slice(0, 80)}…` | |
| : (qn.canonical_text ?? qn.question_id)} | |
| </option> | |
| ))} | |
| </select> | |
| <p className="mt-1 text-xs text-gray-500"> | |
| Limits decisions to those deciding this policy question. | |
| </p> | |
| </div> | |
| </div> | |
| {/* Full Text Search Checkbox */} | |
| <div className="pt-4 border-t border-gray-200"> | |
| <label className="flex items-center gap-2 cursor-pointer group"> | |
| <input | |
| type="checkbox" | |
| checked={includeFullText} | |
| onChange={(e) => { | |
| setIncludeFullText(e.target.checked) | |
| setCurrentPage(1) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="h-4 w-4 text-primary-600 border-gray-300 rounded focus:ring-2 focus:ring-primary-500 cursor-pointer" | |
| /> | |
| <div className="flex-1"> | |
| <span className="text-sm font-medium text-gray-700 group-hover:text-gray-900 block"> | |
| Include full text | |
| </span> | |
| <span className="text-xs text-gray-500 block"> | |
| (bills, meeting transcripts & summaries) | |
| </span> | |
| </div> | |
| </label> | |
| </div> | |
| </div> | |
| {/* Footer Actions */} | |
| <div className="mt-8 pt-6 border-t border-gray-200 space-y-3"> | |
| <button | |
| onClick={() => { | |
| setSelectedState('') | |
| setSortBy('relevance') | |
| setNteeCategory('') | |
| setSelectedTopicId('') | |
| setSelectedCauseId('') | |
| setSelectedQuestionId('') | |
| setSelectedDocType('') | |
| setIncludeFullText(false) | |
| setSelectedTypes([...DEFAULT_RESULT_TYPES]) | |
| setTimeout(() => handleSearch(), 0) | |
| }} | |
| className="w-full px-4 py-2.5 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 transition-colors font-medium" | |
| > | |
| Clear All Filters | |
| </button> | |
| <button | |
| onClick={() => setShowFilters(false)} | |
| className="w-full px-4 py-2.5 bg-primary-600 text-white rounded-md hover:bg-primary-700 transition-colors font-medium" | |
| > | |
| Apply Filters | |
| </button> | |
| </div> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| {/* Search Results */} | |
| {(activeQuery || selectedState || searchResults || tabCountsData) && ( | |
| <div> | |
| {(isSearching || isCountsLoading) && ( | |
| <div className="text-center py-12"> | |
| <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div> | |
| <p className="mt-4 text-gray-600">Searching...</p> | |
| </div> | |
| )} | |
| {error && ( | |
| <div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center"> | |
| <p className="text-red-600">Error loading search results. Please try again.</p> | |
| </div> | |
| )} | |
| {/* No matches at all (counts came back empty). Per-tab empties can't | |
| happen — a tab only exists when its count is > 0. */} | |
| {!isCountsLoading && tabCountsData && tabCountsData.total_results === 0 && ( | |
| <div className="text-center py-12 bg-white rounded-lg border border-gray-200"> | |
| <MagnifyingGlassIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" /> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-2">No results found</h3> | |
| <p className="text-gray-600"> | |
| Try different keywords or adjust your filters | |
| </p> | |
| </div> | |
| )} | |
| {searchResults && searchResults.total_results !== undefined && searchResults.pagination && ( | |
| <> | |
| {/* Results Summary — headline is the GRAND total across all | |
| tabs (from the counts call); the sub-line describes the | |
| active tab's current page (pagination is per-tab). */} | |
| <div className="mb-6"> | |
| <h2 className="text-xl font-semibold text-gray-900"> | |
| {(tabCountsData?.total_results ?? searchResults.total_results).toLocaleString()} results | |
| {searchResults.query ? ` for "${searchResults.query}"` : ''} | |
| </h2> | |
| {searchResults.total_results > 0 && ( | |
| <p className="text-sm text-gray-600 mt-1"> | |
| Showing {searchResults.pagination.offset + 1}– | |
| {Math.min(searchResults.pagination.offset + searchResults.pagination.limit, searchResults.total_results)} | |
| {' '}of {searchResults.total_results.toLocaleString()}{' '} | |
| {(RESULT_TABS.find((t) => t.key === effectiveTab)?.label ?? effectiveTab).toLowerCase()} | |
| {selectedState && ` · State: ${selectedState}`} | |
| {selectedCity && (countyScopeOn | |
| ? ` · County: ${placeScopeLabel}` | |
| : ` · City: ${selectedCity}`)} | |
| </p> | |
| )} | |
| </div> | |
| {/* Jurisdiction Details Breakdown */} | |
| {jurisdictionDetails.length > 0 && ( | |
| <div className="mb-6 p-6 bg-gradient-to-br from-teal-50 to-blue-50 rounded-xl border border-teal-200"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2"> | |
| <MapPinIcon className="h-6 w-6 text-teal-600" /> | |
| Your Jurisdictions | |
| </h3> | |
| <p className="text-sm text-gray-600 mb-4"> | |
| When you select a city, you're connected to {jurisdictionDetails.length} levels of government: | |
| </p> | |
| <div className="space-y-3"> | |
| {jurisdictionDetails.map((item: any, index: number) => { | |
| const isExpanded = expandedJurisdictions.has(index) | |
| return ( | |
| <div | |
| key={index} | |
| className="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden transition-all duration-200 hover:shadow-md" | |
| > | |
| {/* Collapsed Header - Always Visible */} | |
| <button | |
| onClick={() => toggleJurisdictionExpansion(index)} | |
| className="w-full flex items-center justify-between p-4 text-left hover:bg-gray-50 transition-colors" | |
| > | |
| <div className="flex-1"> | |
| <div className="flex items-center gap-2"> | |
| <span className="font-semibold text-gray-900">{item.type}</span> | |
| <span className="text-gray-400">•</span> | |
| <span className="text-gray-700">{item.name}</span> | |
| </div> | |
| {!isExpanded && ( | |
| <div className="text-sm text-gray-500 mt-1"> | |
| Click to view details and discover data sources | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex items-center gap-3"> | |
| <CheckIcon className="h-5 w-5 text-green-600 flex-shrink-0" /> | |
| {isExpanded ? ( | |
| <ChevronUpIcon className="h-5 w-5 text-gray-400 flex-shrink-0" /> | |
| ) : ( | |
| <ChevronDownIcon className="h-5 w-5 text-gray-400 flex-shrink-0" /> | |
| )} | |
| </div> | |
| </button> | |
| {/* Expanded Details */} | |
| {isExpanded && ( | |
| <div className="px-4 pb-4 border-t border-gray-100 bg-gray-50"> | |
| <div className="mt-4 space-y-3"> | |
| {/* Jurisdiction Info */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div> | |
| <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Type</div> | |
| <div className="text-sm text-gray-900">{item.type}</div> | |
| </div> | |
| <div> | |
| <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Name</div> | |
| <div className="text-sm text-gray-900">{item.name}</div> | |
| </div> | |
| {item.count !== undefined && ( | |
| <div> | |
| <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-1">Count</div> | |
| <div className="text-sm text-gray-900">{item.count.toLocaleString()}</div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Discover Data Sources Button */} | |
| <div className="pt-3 border-t border-gray-200"> | |
| <div className="bg-blue-50 border border-blue-200 rounded-lg p-4"> | |
| <div className="flex items-start gap-3"> | |
| <div className="flex-shrink-0"> | |
| <GlobeAltIcon className="h-5 w-5 text-blue-600" /> | |
| </div> | |
| <div className="flex-1"> | |
| <h4 className="text-sm font-semibold text-blue-900 mb-1"> | |
| Automated Data Discovery | |
| </h4> | |
| <p className="text-xs text-blue-700 mb-3"> | |
| Automatically find official websites, meeting agendas, YouTube channels, and social media for this jurisdiction. | |
| </p> | |
| <button | |
| onClick={(e) => { | |
| e.stopPropagation() | |
| // Navigate to discovery page | |
| const searchQuery = `${item.name} ${item.type}` | |
| navigate(`/discovery?q=${encodeURIComponent(searchQuery)}&jurisdiction=${encodeURIComponent(item.name)}`) | |
| }} | |
| className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 transition-colors" | |
| > | |
| <MagnifyingGlassIcon className="h-4 w-4" /> | |
| Discover Data Sources | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* What We'll Find */} | |
| <div className="pt-3 border-t border-gray-200"> | |
| <div className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2"> | |
| What We'll Discover | |
| </div> | |
| <div className="grid grid-cols-2 gap-2 text-sm"> | |
| <div className="flex items-center gap-2 text-gray-700"> | |
| <GlobeAltIcon className="h-4 w-4 text-gray-400" /> | |
| Official Website | |
| </div> | |
| <div className="flex items-center gap-2 text-gray-700"> | |
| <CalendarIcon className="h-4 w-4 text-gray-400" /> | |
| Meeting Agendas | |
| </div> | |
| <div className="flex items-center gap-2 text-gray-700"> | |
| <VideoCameraIcon className="h-4 w-4 text-gray-400" /> | |
| YouTube Channels | |
| </div> | |
| <div className="flex items-center gap-2 text-gray-700"> | |
| <BuildingOfficeIcon className="h-4 w-4 text-gray-400" /> | |
| Social Media | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| <div className="mt-4 p-4 bg-blue-100 rounded-lg"> | |
| <p className="text-sm text-blue-900"> | |
| <strong>💡 Why this matters:</strong> Each jurisdiction has its own meetings, budgets, and leaders that affect your daily life. Track all of them in one place. | |
| </p> | |
| </div> | |
| </div> | |
| )} | |
| {/* Two-column results layout: a left filter sidebar (lg+) that | |
| lists each content type with its real result count, and the | |
| right column with the active type's results. On small screens | |
| the sidebar is hidden and the horizontal tab bar is shown. */} | |
| <div className="flex gap-6 lg:gap-8 items-start"> | |
| {/* Left filter sidebar — type list with counts (lg+ only). | |
| Counts come straight from tabCounts (type_totals); no | |
| fabricated numbers. */} | |
| {availableTabs.length > 0 && ( | |
| <aside className="hidden lg:block w-60 shrink-0 lg:sticky lg:top-20"> | |
| <div className="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2 px-3"> | |
| Filter by type | |
| </div> | |
| <nav className="space-y-1" aria-label="Filter by result type"> | |
| {availableTabs.map(({ key, label }) => { | |
| const isActive = effectiveTab === key | |
| return ( | |
| <button | |
| key={key} | |
| onClick={() => selectTab(key)} | |
| aria-current={isActive ? 'page' : undefined} | |
| className={`group flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm font-medium transition-colors ${ | |
| isActive | |
| ? 'bg-primary-50 text-primary-700' | |
| : 'text-gray-600 hover:bg-gray-50' | |
| }`} | |
| > | |
| <span className={isActive ? 'text-primary-600' : 'text-gray-400 group-hover:text-gray-500'}> | |
| {getTypeIcon(key)} | |
| </span> | |
| {label} | |
| <span | |
| className={`ml-auto rounded-full px-2.5 py-1 text-sm font-bold ${ | |
| isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-600' | |
| }`} | |
| > | |
| {tabCounts[key]?.toLocaleString() ?? 0} | |
| </span> | |
| </button> | |
| ) | |
| })} | |
| </nav> | |
| </aside> | |
| )} | |
| {/* Right column — active type's results + pagination. */} | |
| <div className="min-w-0 flex-1"> | |
| {/* Result-type Tabs — one per type that returned hits, Decisions | |
| first. Shown below lg only; the sidebar replaces this on lg+. */} | |
| {availableTabs.length > 1 && ( | |
| <div className="lg:hidden mb-6 border-b border-gray-200 overflow-x-auto scrollbar-hide"> | |
| <nav className="-mb-px flex gap-x-6" aria-label="Result types"> | |
| {availableTabs.map(({ key, label }) => { | |
| const isActive = effectiveTab === key | |
| return ( | |
| <button | |
| key={key} | |
| onClick={() => selectTab(key)} | |
| aria-current={isActive ? 'page' : undefined} | |
| className={`group inline-flex items-center gap-2 whitespace-nowrap border-b-2 px-1 py-3 text-sm font-medium transition-colors ${ | |
| isActive | |
| ? 'border-primary-600 text-primary-700' | |
| : 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700' | |
| }`} | |
| > | |
| <span className={isActive ? 'text-primary-600' : 'text-gray-400 group-hover:text-gray-500'}> | |
| {getTypeIcon(key)} | |
| </span> | |
| {label} | |
| <span | |
| className={`rounded-full px-2.5 py-1 text-sm font-bold ${ | |
| isActive ? 'bg-primary-100 text-primary-700' : 'bg-gray-100 text-gray-600' | |
| }`} | |
| > | |
| {tabCounts[key]?.toLocaleString() ?? 0} | |
| </span> | |
| </button> | |
| ) | |
| })} | |
| </nav> | |
| </div> | |
| )} | |
| {/* Results by Type */} | |
| {effectiveTab === 'leaders' && | |
| searchResults.results?.leaders && | |
| searchResults.results.leaders.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <UserIcon className="h-6 w-6 text-blue-600" /> | |
| Leaders ({searchResults.type_totals?.leaders?.toLocaleString() || searchResults.results.leaders.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.leaders.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'persons' && | |
| searchResults.results?.persons && | |
| searchResults.results.persons.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <UserIcon className="h-6 w-6 text-sky-600" /> | |
| Nonprofit Leaders ({searchResults.type_totals?.persons?.toLocaleString() || searchResults.results.persons.length}) | |
| </h3> | |
| {(() => { | |
| const persons = searchResults.results.persons | |
| // Max revenue across the currently-rendered list, for bar widths. | |
| const maxRevenue = persons.reduce((max, r) => { | |
| const v = Number(r.metadata?.total_revenue) | |
| return Number.isFinite(v) && v > max ? v : max | |
| }, 0) | |
| return ( | |
| <div className="overflow-x-auto rounded-lg border border-gray-200 bg-white"> | |
| <table className="min-w-full divide-y divide-gray-200"> | |
| <thead className="bg-gray-50"> | |
| <tr> | |
| <th className="px-4 py-3 w-12" /> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Leader</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Organization</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Cause</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Total Revenue</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Assets</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Jurisdiction</th> | |
| <th className="px-4 py-3 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">File</th> | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-gray-200"> | |
| {persons.map((result, idx) => { | |
| const m = result.metadata ?? {} | |
| const revenue = Number(m.total_revenue) | |
| const hasRevenue = Number.isFinite(revenue) && m.total_revenue != null | |
| const assets = Number(m.total_assets) | |
| const hasAssets = Number.isFinite(assets) && m.total_assets != null | |
| const barWidth = hasRevenue && maxRevenue > 0 | |
| ? Math.max(2, Math.round((revenue / maxRevenue) * 100)) | |
| : 0 | |
| const cause = m.cause ?? null | |
| const dotColor = cause | |
| ? causeDotColor(String(m.ntee_code ?? m.cause)) | |
| : null | |
| return ( | |
| <tr key={idx} className="hover:bg-gray-50 transition-colors"> | |
| {/* Avatar */} | |
| <td className="px-4 py-3"> | |
| <div | |
| className="w-10 h-10 rounded-full flex items-center justify-center text-white text-base font-bold" | |
| style={{ backgroundColor: '#2F5D62' }} | |
| > | |
| {result.title?.charAt(0) ?? '?'} | |
| </div> | |
| </td> | |
| {/* Leader */} | |
| <td className="px-4 py-3 align-top"> | |
| <div | |
| onClick={() => openResult(result.url)} | |
| className={`font-semibold text-gray-900 ${ | |
| result.url ? 'cursor-pointer hover:text-blue-600' : '' | |
| }`} | |
| > | |
| {result.title} | |
| </div> | |
| <div className="text-xs text-gray-500 mt-0.5"> | |
| {m.title ?? 'Leader'} | |
| </div> | |
| </td> | |
| {/* Organization */} | |
| <td className="px-4 py-3 align-top"> | |
| <div className="text-sm text-gray-900">{m.organization ?? '—'}</div> | |
| {m.ein && ( | |
| <div className="text-xs text-gray-500 mt-0.5">EIN {formatEin(String(m.ein))}</div> | |
| )} | |
| </td> | |
| {/* Cause */} | |
| <td className="px-4 py-3 align-top"> | |
| {cause ? ( | |
| <div className="flex items-center gap-2"> | |
| <span | |
| className="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0" | |
| style={{ backgroundColor: dotColor ?? '#9CA3AF' }} | |
| /> | |
| <span className="text-sm text-gray-700">{cause}</span> | |
| </div> | |
| ) : null} | |
| </td> | |
| {/* Total Revenue */} | |
| <td className="px-4 py-3 align-top"> | |
| {hasRevenue ? ( | |
| <div className="flex items-center gap-2 min-w-[120px]"> | |
| <div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full bg-sky-500 rounded-full" | |
| style={{ width: `${barWidth}%` }} | |
| /> | |
| </div> | |
| <span className="text-xs text-gray-700 whitespace-nowrap"> | |
| {formatCurrency(revenue)} | |
| </span> | |
| </div> | |
| ) : null} | |
| </td> | |
| {/* Assets */} | |
| <td className="px-4 py-3 align-top text-sm text-gray-700 whitespace-nowrap"> | |
| {hasAssets ? formatCurrency(assets) : null} | |
| </td> | |
| {/* Jurisdiction */} | |
| <td className="px-4 py-3 align-top"> | |
| {m.city && <div className="text-sm text-gray-900">{titleCaseCity(m.city)}</div>} | |
| {m.state && <div className="text-xs text-gray-500 mt-0.5">{expandStateName(m.state)}</div>} | |
| </td> | |
| {/* File */} | |
| <td className="px-4 py-3 align-top"> | |
| {m.filing_url ? ( | |
| <a | |
| href={m.filing_url} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="text-gray-400 hover:text-blue-600" | |
| title="View 990 filing" | |
| > | |
| <DocumentTextIcon className="h-5 w-5" /> | |
| </a> | |
| ) : null} | |
| </td> | |
| </tr> | |
| ) | |
| })} | |
| </tbody> | |
| </table> | |
| </div> | |
| ) | |
| })()} | |
| </div> | |
| )} | |
| {effectiveTab === 'meetings' && searchResults.results?.meetings && searchResults.results.meetings.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <CalendarIcon className="h-6 w-6 text-green-600" /> | |
| Meetings ({searchResults.type_totals?.meetings?.toLocaleString() || searchResults.results.meetings.length}) | |
| </h3> | |
| <StoryResultGrid results={searchResults.results.meetings} /> | |
| </div> | |
| )} | |
| {effectiveTab === 'documents' && searchResults.results?.documents && searchResults.results.documents.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <DocumentMagnifyingGlassIcon className="h-6 w-6 text-cyan-600" /> | |
| Transcripts ({searchResults.type_totals?.documents?.toLocaleString() || searchResults.results.documents.length}) | |
| </h3> | |
| <p className="text-sm text-gray-500 mb-4 -mt-2"> | |
| Passages from meeting transcripts that mention your search term — the discussion itself, even when it never became a titled agenda item or a recorded decision. | |
| </p> | |
| <TranscriptResultGrid results={searchResults.results.documents} /> | |
| </div> | |
| )} | |
| {effectiveTab === 'meeting_documents' && searchResults.results?.meeting_documents && searchResults.results.meeting_documents.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <DocumentTextIcon className="h-6 w-6 text-cyan-600" /> | |
| Meeting Documents ({searchResults.type_totals?.meeting_documents?.toLocaleString() || searchResults.results.meeting_documents.length}) | |
| </h3> | |
| <p className="text-sm text-gray-500 mb-4 -mt-2"> | |
| Official meeting PDFs — agendas, notes (minutes), and attachments. Click any document to open it in the PDF viewer. | |
| </p> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.meeting_documents.map((result, idx) => { | |
| const md = (result.metadata ?? {}) as Record<string, any> | |
| // PDF link: prefer the explicit metadata.document_url, fall | |
| // back to result.url (both are the same direct PDF link). | |
| const pdfUrl = (md.document_url as string) || result.url || '' | |
| // Type badge label from metadata.document_type. | |
| const dt = String(md.document_type ?? '').toLowerCase() | |
| const badgeLabel = | |
| dt === 'minutes' ? 'Notes' | |
| : dt === 'attachment' ? 'Attachment' | |
| : dt === 'agenda' ? 'Agenda' | |
| : (md.document_type as string) || 'Document' | |
| const badgeColor = | |
| dt === 'minutes' ? 'bg-amber-100 text-amber-700 border-amber-200' | |
| : dt === 'attachment' ? 'bg-gray-100 text-gray-700 border-gray-200' | |
| : 'bg-cyan-100 text-cyan-700 border-cyan-200' | |
| return ( | |
| <div | |
| key={result.url || idx} | |
| className="bg-white rounded-lg border border-gray-200 p-4 hover:shadow-md transition-shadow" | |
| > | |
| <div className="flex items-start gap-3"> | |
| <div className="p-2 rounded-lg border bg-cyan-100 text-cyan-700 border-cyan-200"> | |
| <DocumentTextIcon className="h-5 w-5" /> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center flex-wrap gap-2 mb-1"> | |
| <span className={`px-2 py-0.5 rounded text-xs font-medium border ${badgeColor}`}> | |
| {badgeLabel} | |
| </span> | |
| {md.body_name && ( | |
| <span className="text-xs text-gray-500">{md.body_name}</span> | |
| )} | |
| </div> | |
| {pdfUrl ? ( | |
| docViewer ? ( | |
| <button | |
| type="button" | |
| onClick={() => | |
| docViewer.openDocument({ | |
| url: pdfUrl, | |
| label: result.title, | |
| caption: [badgeLabel, md.body_name, md.date] | |
| .filter(Boolean) | |
| .join(' • '), | |
| }) | |
| } | |
| className="font-semibold text-gray-900 mb-1 inline-flex items-center gap-1 cursor-pointer text-left hover:text-blue-600" | |
| > | |
| {result.title} | |
| <span aria-hidden="true" className="text-xs text-gray-400">⤢</span> | |
| </button> | |
| ) : ( | |
| <a | |
| href={pdfUrl} | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| className="font-semibold text-gray-900 mb-1 inline-flex items-center gap-1 cursor-pointer hover:text-blue-600" | |
| > | |
| {result.title} | |
| <span aria-hidden="true" className="text-xs text-gray-400">↗</span> | |
| </a> | |
| ) | |
| ) : ( | |
| <h4 className="font-semibold text-gray-900 mb-1">{result.title}</h4> | |
| )} | |
| {result.subtitle && ( | |
| <p className="text-sm text-gray-600 mb-1">{result.subtitle}</p> | |
| )} | |
| <div className="flex flex-wrap gap-x-3 gap-y-1 text-xs text-gray-500 mb-2"> | |
| {md.date && <span>{md.date}</span>} | |
| {md.jurisdiction && <span>{md.jurisdiction}</span>} | |
| </div> | |
| {/* ts_headline snippet: matched passage wrapped in | |
| literal <mark>…</mark> markers. Reuse the shared | |
| highlightSnippet helper so segments are React- | |
| escaped (no dangerouslySetInnerHTML). */} | |
| {result.description && ( | |
| <p className="text-sm text-gray-500 line-clamp-3"> | |
| {highlightSnippet(result.description)} | |
| </p> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'organizations' && searchResults.results?.organizations && searchResults.results.organizations.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <BuildingOfficeIcon className="h-6 w-6 text-purple-600" /> | |
| Organizations ({searchResults.type_totals?.organizations?.toLocaleString() || searchResults.results.organizations.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.organizations.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'causes' && searchResults.results?.causes && searchResults.results.causes.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <HeartIcon className="h-6 w-6 text-pink-600" /> | |
| Causes ({searchResults.type_totals?.causes?.toLocaleString() || searchResults.results.causes.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.causes.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'bills' && searchResults.results?.bills && searchResults.results.bills.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <DocumentTextIcon className="h-6 w-6 text-indigo-600" /> | |
| Bills ({searchResults.type_totals?.bills?.toLocaleString() || searchResults.results.bills.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.bills.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'topics' && searchResults.results?.topics && searchResults.results.topics.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <ChatBubbleBottomCenterTextIcon className="h-6 w-6 text-teal-600" /> | |
| Topics ({searchResults.type_totals?.topics?.toLocaleString() || searchResults.results.topics.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.topics.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Questions — cross-jurisdiction policy questions. Generic | |
| ResultCard handles the title/subtitle/description; result.url | |
| routes to /policy-question/{id} via openResult(). */} | |
| {effectiveTab === 'questions' && searchResults.results?.questions && searchResults.results.questions.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <QuestionMarkCircleIcon className="h-6 w-6 text-violet-600" /> | |
| Questions ({searchResults.type_totals?.questions?.toLocaleString() || searchResults.results.questions.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.questions.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'decisions' && searchResults.results?.decisions && searchResults.results.decisions.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <ScaleIcon className="h-6 w-6 text-amber-600" /> | |
| Decisions ({searchResults.type_totals?.decisions?.toLocaleString() || searchResults.results.decisions.length}) | |
| </h3> | |
| <StoryResultGrid results={searchResults.results.decisions} /> | |
| </div> | |
| )} | |
| {effectiveTab === 'jurisdictions' && searchResults.results?.jurisdictions && searchResults.results.jurisdictions.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <MapPinIcon className="h-6 w-6 text-orange-600" /> | |
| Jurisdictions ({searchResults.type_totals?.jurisdictions?.toLocaleString() || searchResults.results.jurisdictions.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.jurisdictions.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {effectiveTab === 'grants' && searchResults.results?.grants && searchResults.results.grants.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <BanknotesIcon className="h-6 w-6 text-emerald-600" /> | |
| Grants ({searchResults.type_totals?.grants?.toLocaleString() || searchResults.results.grants.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.grants.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* Grant Opportunities — open federal funding (Grants.gov). | |
| Distinct from historical 990 grantmaking ("Grants") above. */} | |
| {effectiveTab === 'grant_opportunities' && searchResults.results?.grant_opportunities && searchResults.results.grant_opportunities.length > 0 && ( | |
| <div className="mb-8"> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-4 flex items-center gap-2"> | |
| <MegaphoneIcon className="h-6 w-6 text-rose-600" /> | |
| Grant Opportunities ({searchResults.type_totals?.grant_opportunities?.toLocaleString() || searchResults.results.grant_opportunities.length}) | |
| </h3> | |
| <div className="grid grid-cols-1 gap-4"> | |
| {searchResults.results.grant_opportunities.map((result, idx) => ( | |
| <ResultCard key={idx} result={result} /> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| {/* No Results */} | |
| {searchResults.total_results === 0 && ( | |
| <div className="text-center py-12 bg-white rounded-lg border border-gray-200"> | |
| <MagnifyingGlassIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" /> | |
| <h3 className="text-lg font-semibold text-gray-900 mb-2">No results found</h3> | |
| <p className="text-gray-600"> | |
| Try different keywords or adjust your filters | |
| </p> | |
| </div> | |
| )} | |
| {/* Pagination Controls */} | |
| {searchResults.total_results > 0 && searchResults.pagination.total_pages > 1 && ( | |
| <div className="mt-8 flex items-center justify-between bg-white rounded-lg border border-gray-200 p-4"> | |
| <div className="text-sm text-gray-600"> | |
| Page {searchResults.pagination.page} of {searchResults.pagination.total_pages} | |
| <span className="ml-2">•</span> | |
| <span className="ml-2">{searchResults.total_results} total results</span> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <button | |
| onClick={() => handlePageChange(searchResults.pagination.page - 1)} | |
| disabled={!searchResults.pagination.has_prev} | |
| className={`px-4 py-2 rounded-lg font-medium transition-colors ${ | |
| searchResults.pagination.has_prev | |
| ? 'bg-primary-600 text-white hover:bg-primary-700' | |
| : 'bg-gray-100 text-gray-400 cursor-not-allowed' | |
| }`} | |
| > | |
| ← Previous | |
| </button> | |
| {/* Page numbers */} | |
| <div className="flex items-center gap-1"> | |
| {Array.from({ length: Math.min(5, searchResults.pagination.total_pages) }, (_, i) => { | |
| const pageNum = Math.max( | |
| 1, | |
| Math.min( | |
| searchResults.pagination.page - 2 + i, | |
| searchResults.pagination.total_pages - 4 | |
| ) | |
| ) + Math.min(i, 4) | |
| if (pageNum > searchResults.pagination.total_pages) return null | |
| return ( | |
| <button | |
| key={pageNum} | |
| onClick={() => handlePageChange(pageNum)} | |
| className={`px-3 py-1 rounded ${ | |
| pageNum === searchResults.pagination.page | |
| ? 'bg-primary-600 text-white font-semibold' | |
| : 'bg-gray-100 text-gray-700 hover:bg-gray-200' | |
| }`} | |
| > | |
| {pageNum} | |
| </button> | |
| ) | |
| })} | |
| </div> | |
| <button | |
| onClick={() => handlePageChange(searchResults.pagination.page + 1)} | |
| disabled={!searchResults.pagination.has_next} | |
| className={`px-4 py-2 rounded-lg font-medium transition-colors ${ | |
| searchResults.pagination.has_next | |
| ? 'bg-primary-600 text-white hover:bg-primary-700' | |
| : 'bg-gray-100 text-gray-400 cursor-not-allowed' | |
| }`} | |
| > | |
| Next → | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| </div>{/* /right column */} | |
| </div>{/* /two-column results layout */} | |
| </> | |
| )} | |
| </div> | |
| )} | |
| {/* Initial State - Search Examples */} | |
| {!activeQuery && ( | |
| <div className="bg-white rounded-lg shadow-sm p-8"> | |
| {(selectedState || selectedTypes.length < 5) && ( | |
| <div className="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg"> | |
| <p className="text-blue-800 font-medium"> | |
| {selectedState && `State filter: ${selectedState}`} | |
| {selectedState && selectedTypes.length < 5 && ' • '} | |
| {selectedTypes.length < 5 && `Type filter: ${selectedTypes.join(', ')}`} | |
| </p> | |
| <p className="text-blue-700 text-sm mt-1"> | |
| Enter a search query above to see results with these filters applied. | |
| </p> | |
| </div> | |
| )} | |
| <h2 className="text-xl font-semibold text-gray-900 mb-6">Try searching for:</h2> | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| {[ | |
| { query: 'dental health', icon: HeartIcon, description: 'Find organizations and meetings about dental health' }, | |
| { query: 'affordable housing', icon: BuildingOfficeIcon, description: 'Discover housing-related initiatives' }, | |
| { query: 'school board', icon: CalendarIcon, description: 'View school board meetings and decisions' }, | |
| { query: 'mental health', icon: HeartIcon, description: 'Explore mental health programs and services' }, | |
| ].map((example, idx) => ( | |
| <button | |
| key={idx} | |
| onClick={() => { | |
| setQuery(example.query) | |
| setActiveQuery(example.query) | |
| }} | |
| className="text-left p-4 border-2 border-gray-200 rounded-lg hover:border-primary-500 hover:bg-primary-50 transition-colors" | |
| > | |
| <div className="flex items-center gap-3 mb-2"> | |
| <example.icon className="h-6 w-6 text-primary-600" /> | |
| <span className="font-semibold text-gray-900">{example.query}</span> | |
| </div> | |
| <p className="text-sm text-gray-600">{example.description}</p> | |
| </button> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ) | |
| } | |
| export default function UnifiedSearch() { | |
| return ( | |
| <DocumentViewerProvider> | |
| <UnifiedSearchInner /> | |
| </DocumentViewerProvider> | |
| ) | |
| } | |