import { useEffect, useMemo, useState, type ReactNode } from 'react' import { useNavigate } from 'react-router-dom' import { useQuery, keepPreviousData } from '@tanstack/react-query' import { AdjustmentsHorizontalIcon, MagnifyingGlassIcon, XMarkIcon, } from '@heroicons/react/24/outline' import { fetchDecisions, type DecisionSort } from '../api/decisions' import { StoryCard, toRenderCards, CONTESTED_LENS } from './StoryLenses' import StateSelect from './StateSelect' /** * DecisionCardList — a reusable, meeting-level decision browser. * * Renders the SAME Contested-lens StoryCard used on the homepage, backed by * `GET /api/decisions` (via `fetchDecisions`). Scope it to a topic, a policy * question, a place, or a free-text seed; it adds a search bar, a sort control, * and "Load more" pagination on top. * * 100% live data — honest loading / error / empty states, never fabricated * cards or counts (CLAUDE.md: No Fabricated Data). */ const PAGE_SIZE = 24 const SORTS: { id: DecisionSort; label: string }[] = [ { id: 'contested', label: 'Most contested' }, { id: 'recent', label: 'Most recent' }, { id: 'interesting', label: 'Most interesting' }, ] interface DecisionCardListProps { /** One or more civicsearch topic ids — decisions matching ANY are shown. */ topicIds?: number[] /** EveryOrg cause slug — scopes decisions to that cause via the decision-text * keyword path (cause -> decision -> meeting, transcript fallback). */ causeId?: string questionId?: string /** Meeting id — drill into a single meeting's decisions. */ meetingId?: number /** 2-letter state code or full state name. */ state?: string city?: string /** Heading shown above the list, e.g. "Decisions on Affordable housing". */ title?: string /** Seeds the search box / `q` param (used by the causes page, which has no * dedicated cause filter on the decisions endpoint). */ initialQuery?: string /** Show the "Advanced filters" toggle (state / city) next to the search box. * Off by default so scoped pages (a single state/city) aren't cluttered. */ showAdvancedFilters?: boolean /** Optional scope control rendered at the top of the Filters flyout — e.g. the * page's Topic / Cause / Question dropdown, so the active content scope can be * changed in the same panel as Sort / State / City. The parent owns its state * (and remounts this list on change via `key`). */ scopeFilter?: ReactNode } export default function DecisionCardList({ topicIds, causeId, questionId, meetingId, state, city, title, initialQuery, showAdvancedFilters = false, scopeFilter, }: DecisionCardListProps) { const navigate = useNavigate() const [rawQuery, setRawQuery] = useState(initialQuery ?? '') // Debounced text actually sent to the API, so we don't fire per keystroke. const [debouncedQuery, setDebouncedQuery] = useState(initialQuery ?? '') const [sort, setSort] = useState('contested') const [page, setPage] = useState(0) // State / city filters live in the flyout and are the single source of truth. // They SEED from the `state`/`city` props (e.g. a place carried in from the // homepage URL) but stay user-editable — so the flyout works on every page, // not just the unscoped ones. The parent remounts us (via `key`) when its own // scope changes, which re-seeds these. City stays free-text; state is a // canonical dropdown. Raw inputs are debounced before hitting the API. const [advancedOpen, setAdvancedOpen] = useState(false) const [rawState, setRawState] = useState(state ?? '') const [rawCity, setRawCity] = useState(city ?? '') const [debouncedState, setDebouncedState] = useState(state ?? '') const [debouncedCity, setDebouncedCity] = useState(city ?? '') // Session-local "saved" bookmarks, keyed like the homepage carousel. const [savedKeys, setSavedKeys] = useState>(() => new Set()) const toggleSave = (key: string) => setSavedKeys((prev) => { const next = new Set(prev) if (next.has(key)) next.delete(key) else next.add(key) return next }) // Debounce the search box + advanced filters (~300ms) and reset to the first // page whenever any of them change. useEffect(() => { const t = setTimeout(() => { setDebouncedQuery(rawQuery.trim()) setDebouncedState(rawState.trim()) setDebouncedCity(rawCity.trim()) setPage(0) }, 300) return () => clearTimeout(t) }, [rawQuery, rawState, rawCity]) // Stable key/value for the topic-id set (order-independent) used in the // query cache key and effect deps. const topicKey = (topicIds ?? []).join(',') // Reset to the first page when the scope or sort changes. useEffect(() => { setPage(0) }, [topicKey, causeId, questionId, meetingId, sort]) const q = debouncedQuery || undefined // The flyout inputs (seeded from props) are authoritative for state/city. const effectiveState = debouncedState || undefined const effectiveCity = debouncedCity || undefined // Count of active filters, for the Filters button badge: non-default sort // plus any state/city narrowing. const advancedCount = (rawState.trim() ? 1 : 0) + (rawCity.trim() ? 1 : 0) const activeFilterCount = advancedCount + (sort !== 'contested' ? 1 : 0) const { data, isLoading, isError, isFetching } = useQuery({ queryKey: [ 'decisions', topicKey || null, causeId ?? null, questionId ?? null, meetingId ?? null, effectiveState ?? null, effectiveCity ?? null, q ?? null, sort, page, ], queryFn: () => fetchDecisions({ topicIds: topicIds && topicIds.length ? topicIds : undefined, causeId, questionId, meetingId, state: effectiveState, city: effectiveCity, q, sort, limit: PAGE_SIZE, offset: page * PAGE_SIZE, }), placeholderData: keepPreviousData, }) const unscoped = !effectiveState && !effectiveCity const cards = useMemo(() => toRenderCards(data?.items ?? [], unscoped), [data, unscoped]) const total = data?.pagination.total ?? 0 const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const cardKey = (url: string | undefined, i: number) => url || `decision-${page}-${i}` return (
{/* Heading + real total count */}
{title ?

{title}

: } {!isLoading && !isError && ( {total.toLocaleString()} {total === 1 ? 'decision' : 'decisions'} )}
{/* Search bar + a single Filters button on the same row — matches the Search page. Sort and the optional state/city filters live inside the flyout panel, so the row stays clean. */}
{ e.preventDefault() // Flush the debounce so the button / Enter searches immediately. setDebouncedQuery(rawQuery.trim()) setDebouncedState(rawState.trim()) setDebouncedCity(rawCity.trim()) setPage(0) }} className="mb-5 flex items-stretch gap-3" >
setRawQuery(e.target.value)} placeholder="Search these decisions…" className="w-full rounded-lg border-2 border-gray-300 bg-white py-3 pl-4 pr-10 text-base text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500" /> {rawQuery && ( )}
{/* Filter panel — a right-side flyout (matches the Search & Jurisdictions pages): backdrop + fixed drawer. Sort (always) + optional state/city server-side filters live inside. */} {advancedOpen && ( <>
setAdvancedOpen(false)} aria-hidden="true" />

Filters

{scopeFilter && (
{scopeFilter}
)}
Sort by
{SORTS.map((s) => { const on = sort === s.id return ( ) })}
{showAdvancedFilters && (
)} {activeFilterCount > 0 && ( )}
)} {/* Body — honest loading / error / empty / grid states */} {isLoading ? (
{Array.from({ length: 6 }).map((_, i) => (
))}
) : isError ? (
Couldn't load decisions. Please try again.
) : cards.length === 0 ? (
{debouncedQuery ? `No decisions match “${debouncedQuery}”.` : 'No decisions here yet.'}
) : ( <>
{cards.map((card, i) => { const key = cardKey(card.url, i) return ( toggleSave(key)} onOpen={() => card.url && navigate(card.url)} /> ) })}
{/* Pagination — only when there's more than one page of real results. */} {totalPages > 1 && (
Page {page + 1} of {totalPages.toLocaleString()}
)} )}
) }