Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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'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> | |
| ) | |
| } | |