open-navigator / web_app /src /components /DecisionCardList.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
16 kB
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<DecisionSort>('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<Set<string>>(() => 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 (
<div>
{/* Heading + real total count */}
<div className="mb-4 flex flex-wrap items-baseline justify-between gap-2">
{title ? <h2 className="text-xl font-bold text-gray-900">{title}</h2> : <span />}
{!isLoading && !isError && (
<span className="text-sm text-gray-500">
{total.toLocaleString()} {total === 1 ? 'decision' : 'decisions'}
</span>
)}
</div>
{/* 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. */}
<form
onSubmit={(e) => {
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"
>
<div className="relative flex-1">
<input
type="text"
value={rawQuery}
onChange={(e) => 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 && (
<button
type="button"
onClick={() => setRawQuery('')}
aria-label="Clear search"
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
<XMarkIcon className="h-5 w-5" />
</button>
)}
</div>
<button
type="submit"
aria-label="Search"
className="flex shrink-0 items-center justify-center rounded-lg bg-primary-600 px-4 text-white transition-colors hover:bg-primary-700"
>
<MagnifyingGlassIcon className="h-5 w-5" />
</button>
<button
type="button"
onClick={() => setAdvancedOpen((v) => !v)}
aria-expanded={advancedOpen}
className={`flex shrink-0 items-center gap-2 rounded-lg border-2 px-3 py-3 text-sm font-medium transition-colors sm:px-4 ${
advancedOpen
? '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-5 w-5" />
<span>Filters</span>
{activeFilterCount > 0 && (
<span className="ml-0.5 inline-flex h-5 min-w-[1.25rem] items-center justify-center rounded-full bg-primary-600 px-1 text-xs font-bold text-white">
{activeFilterCount}
</span>
)}
</button>
</form>
{/* 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 && (
<>
<div
className="fixed inset-0 z-40 bg-black bg-opacity-50"
onClick={() => setAdvancedOpen(false)}
aria-hidden="true"
/>
<div
className="fixed right-0 top-0 z-50 h-full w-full overflow-y-auto bg-white shadow-2xl md:w-96"
role="dialog"
aria-label="Filters"
>
<div className="flex items-center justify-between border-b border-gray-200 p-4">
<h3 className="text-lg font-bold text-gray-900">Filters</h3>
<button
type="button"
onClick={() => setAdvancedOpen(false)}
aria-label="Close filters"
className="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<div className="space-y-6 p-6">
{scopeFilter && (
<div className="grid grid-cols-1 gap-3">{scopeFilter}</div>
)}
<div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500">Sort by</div>
<div className="inline-flex flex-wrap rounded-full border border-gray-200 bg-white p-1">
{SORTS.map((s) => {
const on = sort === s.id
return (
<button
key={s.id}
type="button"
onClick={() => setSort(s.id)}
className={`rounded-full px-3 py-1.5 text-xs font-semibold transition-colors ${
on ? 'bg-primary-500 text-white shadow-sm' : 'text-gray-600 hover:text-gray-900'
}`}
>
{s.label}
</button>
)
})}
</div>
</div>
{showAdvancedFilters && (
<div className="grid grid-cols-1 gap-3">
<label className="flex flex-col gap-1 text-xs font-semibold text-gray-600">
State
<StateSelect
value={rawState}
onChange={setRawState}
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-normal text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</label>
<label className="flex flex-col gap-1 text-xs font-semibold text-gray-600">
City
<input
type="text"
value={rawCity}
onChange={(e) => setRawCity(e.target.value)}
placeholder="e.g. Sherborn"
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-normal text-gray-900 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
</label>
</div>
)}
{activeFilterCount > 0 && (
<button
type="button"
onClick={() => {
setRawState('')
setRawCity('')
setSort('contested')
}}
className="text-xs font-semibold text-primary-600 hover:text-primary-700"
>
Reset filters
</button>
)}
</div>
</div>
</>
)}
{/* Body — honest loading / error / empty / grid states */}
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-48 animate-pulse rounded-2xl border border-gray-200 bg-gray-100" />
))}
</div>
) : isError ? (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center text-sm text-red-700">
Couldn&apos;t load decisions. Please try again.
</div>
) : cards.length === 0 ? (
<div className="rounded-lg border border-dashed border-gray-300 bg-white p-10 text-center text-sm text-gray-500">
{debouncedQuery
? `No decisions match “${debouncedQuery}”.`
: 'No decisions here yet.'}
</div>
) : (
<>
<div
className={`grid grid-cols-1 gap-4 transition-opacity sm:grid-cols-2 lg:grid-cols-3 ${
isFetching ? 'opacity-60' : ''
}`}
>
{cards.map((card, i) => {
const key = cardKey(card.url, i)
return (
<StoryCard
key={key}
card={card}
lens={CONTESTED_LENS}
saved={savedKeys.has(key)}
onToggleSave={() => toggleSave(key)}
onOpen={() => card.url && navigate(card.url)}
/>
)
})}
</div>
{/* Pagination — only when there's more than one page of real results. */}
{totalPages > 1 && (
<div className="mt-8 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300"
>
Previous
</button>
<span className="text-sm text-gray-500">
Page {page + 1} of {totalPages.toLocaleString()}
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed disabled:text-gray-300"
>
Next
</button>
</div>
)}
</>
)}
</div>
)
}