| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useSearchParams } from "next/navigation"; |
| 5 | import Link from "next/link"; |
| 6 | import { SourceLink } from "@/components/source-link"; |
| 7 | import { StatusPill, classifyStatus } from "@/components/status-pill"; |
| 8 | import { formatDate, congressLabel, formatBillNumber, ordinal } from "@/lib/format"; |
| 9 | import { |
| 10 | SearchFilterBar, |
| 11 | FilterField, |
| 12 | FilterInput, |
| 13 | FilterSelect, |
| 14 | FilterSubmit, |
| 15 | } from "@/components/search-filter-bar"; |
| 16 | import type { BillListItem } from "@/lib/feed"; |
| 17 | |
| 18 | const STATUS_SHORT: Record<string, string> = { |
| 19 | passed: "Passed", |
| 20 | failed: "Failed", |
| 21 | held: "In committee", |
| 22 | pending: "Pending", |
| 23 | neutral: "Introduced", |
| 24 | }; |
| 25 | |
| 26 | function BillsSkeleton() { |
| 27 | return ( |
| 28 | <div className="mt-8 space-y-4"> |
| 29 | <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" /> |
| 30 | <div className="mt-8 mb-4 h-4 w-40 animate-pulse rounded-sm bg-graphite" /> |
| 31 | <div className="space-y-3"> |
| 32 | {Array.from({ length: 16 }).map((_, i) => ( |
| 33 | <div key={i} className="h-24 w-full animate-pulse rounded-sm bg-graphite" /> |
| 34 | ))} |
| 35 | </div> |
| 36 | </div> |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | function BillCards({ bills }: { bills: BillListItem[] }) { |
| 41 | return ( |
| 42 | <ul |
| 43 | style={{ display: "flex", flexDirection: "column", gap: "12px", listStyle: "none", padding: 0, margin: 0 }} |
| 44 | role="list" |
| 45 | > |
| 46 | {bills.map((b) => { |
| 47 | const variant = classifyStatus(b.status); |
| 48 | const cong = congressLabel(b.congress); |
| 49 | return ( |
| 50 | <li key={b.id}> |
| 51 | <div |
| 52 | style={{ |
| 53 | borderRadius: "10px", |
| 54 | border: "1px solid var(--dk-line)", |
| 55 | background: "var(--dk-surface)", |
| 56 | padding: "clamp(20px, 3vw, 28px)", |
| 57 | transition: "border-color 0.2s, background 0.2s", |
| 58 | }} |
| 59 | > |
| 60 | <div |
| 61 | style={{ |
| 62 | display: "flex", |
| 63 | flexWrap: "wrap", |
| 64 | alignItems: "flex-start", |
| 65 | justifyContent: "space-between", |
| 66 | gap: "12px", |
| 67 | marginBottom: "12px", |
| 68 | }} |
| 69 | > |
| 70 | <span |
| 71 | className="font-mono" |
| 72 | style={{ fontSize: "13px", color: "var(--dk-ash-2)", letterSpacing: "0.06em", fontWeight: 700 }} |
| 73 | > |
| 74 | {formatBillNumber(b.number)} |
| 75 | {cong ? ( |
| 76 | <span style={{ color: "var(--dk-ash-2)", fontWeight: 400, marginLeft: "8px" }}> |
| 77 | {cong} |
| 78 | </span> |
| 79 | ) : null} |
| 80 | </span> |
| 81 | <StatusPill variant={variant}> |
| 82 | {STATUS_SHORT[variant] ?? "Introduced"} |
| 83 | </StatusPill> |
| 84 | </div> |
| 85 | |
| 86 | <Link |
| 87 | href={b.href} |
| 88 | className="focus-ring font-prose text-paper" |
| 89 | style={{ |
| 90 | display: "block", |
| 91 | fontSize: "clamp(1rem, 1.8vw, 1.125rem)", |
| 92 | lineHeight: 1.5, |
| 93 | textDecoration: "underline", |
| 94 | textDecorationColor: "var(--dk-line)", |
| 95 | textUnderlineOffset: "3px", |
| 96 | marginBottom: "16px", |
| 97 | }} |
| 98 | > |
| 99 | {b.title} |
| 100 | </Link> |
| 101 | |
| 102 | <div style={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: "16px" }}> |
| 103 | {b.policyArea ? ( |
| 104 | <Link |
| 105 | href={`/bills?policyArea=${encodeURIComponent(b.policyArea)}`} |
| 106 | className="focus-ring label hover:underline" |
| 107 | style={{ color: "var(--dk-signal)", fontSize: "12px", letterSpacing: "0.1em" }} |
| 108 | > |
| 109 | {b.policyArea} |
| 110 | </Link> |
| 111 | ) : null} |
| 112 | <span className="font-mono" style={{ fontSize: "12px", color: "var(--dk-ash-2)" }}> |
| 113 | {formatDate(b.updatedAt)} |
| 114 | </span> |
| 115 | <SourceLink url={b.sourceUrl} /> |
| 116 | </div> |
| 117 | </div> |
| 118 | </li> |
| 119 | ); |
| 120 | })} |
| 121 | </ul> |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | function dropParam(current: URLSearchParams, key: string): string { |
| 126 | const next = new URLSearchParams(current.toString()); |
| 127 | next.delete(key); |
| 128 | const s = next.toString(); |
| 129 | return s ? `/bills?${s}` : "/bills"; |
| 130 | } |
| 131 | |
| 132 | export function BillsBrowser({ |
| 133 | policyAreas, |
| 134 | congresses, |
| 135 | }: { |
| 136 | policyAreas: string[]; |
| 137 | congresses: number[]; |
| 138 | }) { |
| 139 | const sp = useSearchParams(); |
| 140 | const policyArea = sp.get("policyArea") ?? ""; |
| 141 | const q = sp.get("q") ?? ""; |
| 142 | const congressRaw = sp.get("congress") ?? ""; |
| 143 | const congressNum = parseInt(congressRaw, 10); |
| 144 | const sortParam = sp.get("sort") ?? ""; |
| 145 | |
| 146 | const [bills, setBills] = useState<BillListItem[]>([]); |
| 147 | const [total, setTotal] = useState(0); |
| 148 | const [loading, setLoading] = useState(true); |
| 149 | const [shown, setShown] = useState(50); |
| 150 | |
| 151 | useEffect(() => { |
| 152 | setLoading(true); |
| 153 | setShown(50); |
| 154 | const params = new URLSearchParams(); |
| 155 | if (policyArea) params.set("policyArea", policyArea); |
| 156 | if (q) params.set("q", q); |
| 157 | if (congressRaw) params.set("congress", congressRaw); |
| 158 | if (sortParam) params.set("sort", sortParam); |
| 159 | params.set("limit", "100"); |
| 160 | |
| 161 | fetch(`/api/bills?${params.toString()}`) |
| 162 | .then((r) => r.json()) |
| 163 | .then((data) => { |
| 164 | setBills(data.items ?? []); |
| 165 | setTotal(data.total ?? 0); |
| 166 | }) |
| 167 | .catch(() => { |
| 168 | setBills([]); |
| 169 | setTotal(0); |
| 170 | }) |
| 171 | .finally(() => setLoading(false)); |
| 172 | }, [policyArea, q, congressRaw, sortParam]); |
| 173 | |
| 174 | const isFiltered = !!(policyArea || q || Number.isFinite(congressNum) || sortParam); |
| 175 | |
| 176 | const activeChips: Array<{ label: string; href: string }> = []; |
| 177 | if (policyArea) |
| 178 | activeChips.push({ label: `Area: ${policyArea}`, href: dropParam(sp, "policyArea") }); |
| 179 | if (q) |
| 180 | activeChips.push({ label: `Search: ${q}`, href: dropParam(sp, "q") }); |
| 181 | if (Number.isFinite(congressNum)) |
| 182 | activeChips.push({ label: `Congress: ${congressNum}`, href: dropParam(sp, "congress") }); |
| 183 | |
| 184 | if (loading) { |
| 185 | return <BillsSkeleton />; |
| 186 | } |
| 187 | |
| 188 | return ( |
| 189 | <div> |
| 190 | {} |
| 191 | {activeChips.length > 0 ? ( |
| 192 | <div className="flex flex-wrap items-center gap-2" style={{ marginBottom: "16px" }}> |
| 193 | {activeChips.map((c) => ( |
| 194 | <Link |
| 195 | key={c.label} |
| 196 | href={c.href} |
| 197 | className="focus-ring no-underline" |
| 198 | style={{ |
| 199 | display: "inline-flex", |
| 200 | alignItems: "center", |
| 201 | gap: "6px", |
| 202 | minHeight: "44px", |
| 203 | padding: "0 14px", |
| 204 | border: "1px solid var(--dk-line)", |
| 205 | borderRadius: "8px", |
| 206 | fontFamily: "var(--font-mono), ui-monospace, monospace", |
| 207 | fontSize: "12px", |
| 208 | letterSpacing: "0.08em", |
| 209 | textTransform: "uppercase", |
| 210 | color: "var(--dk-ash)", |
| 211 | }} |
| 212 | > |
| 213 | <span aria-hidden="true">×</span> {c.label} |
| 214 | </Link> |
| 215 | ))} |
| 216 | <Link |
| 217 | href="/bills" |
| 218 | className="focus-ring no-underline" |
| 219 | style={{ |
| 220 | display: "inline-flex", |
| 221 | alignItems: "center", |
| 222 | minHeight: "44px", |
| 223 | padding: "0 14px", |
| 224 | fontFamily: "var(--font-mono), ui-monospace, monospace", |
| 225 | fontSize: "12px", |
| 226 | letterSpacing: "0.08em", |
| 227 | textTransform: "uppercase", |
| 228 | color: "var(--dk-paper)", |
| 229 | textDecoration: "underline", |
| 230 | textUnderlineOffset: "4px", |
| 231 | }} |
| 232 | > |
| 233 | Clear all |
| 234 | </Link> |
| 235 | </div> |
| 236 | ) : null} |
| 237 | |
| 238 | {} |
| 239 | <div className="mt-8"> |
| 240 | <form method="get" action="/bills" className="contents"> |
| 241 | <SearchFilterBar |
| 242 | count={total} |
| 243 | countLabel="bill" |
| 244 | clearHref={isFiltered ? "/bills" : undefined} |
| 245 | > |
| 246 | <FilterField label="Search bills" span="2"> |
| 247 | <FilterInput |
| 248 | name="q" |
| 249 | defaultValue={q} |
| 250 | placeholder="Bill number or title…" |
| 251 | aria-label="Search bills by number or title" |
| 252 | /> |
| 253 | </FilterField> |
| 254 | <FilterField label="Policy area"> |
| 255 | <FilterSelect |
| 256 | name="policyArea" |
| 257 | defaultValue={policyArea} |
| 258 | aria-label="Filter by policy area" |
| 259 | options={[ |
| 260 | { value: "", label: "Any area" }, |
| 261 | ...policyAreas.map((p) => ({ value: p, label: p })), |
| 262 | ]} |
| 263 | /> |
| 264 | </FilterField> |
| 265 | <FilterField label="Congress"> |
| 266 | <FilterSelect |
| 267 | name="congress" |
| 268 | defaultValue={Number.isFinite(congressNum) ? String(congressNum) : ""} |
| 269 | aria-label="Filter by Congress number" |
| 270 | options={[ |
| 271 | { value: "", label: "Any congress" }, |
| 272 | ...congresses.map((c) => ({ value: String(c), label: ordinal(c) })), |
| 273 | ]} |
| 274 | /> |
| 275 | </FilterField> |
| 276 | <FilterField label="Sort by"> |
| 277 | <FilterSelect |
| 278 | name="sort" |
| 279 | defaultValue={sortParam} |
| 280 | aria-label="Sort bills" |
| 281 | options={[ |
| 282 | { value: "", label: "Newest action" }, |
| 283 | { value: "congress", label: "By congress" }, |
| 284 | { value: "status", label: "By status" }, |
| 285 | ]} |
| 286 | /> |
| 287 | </FilterField> |
| 288 | <FilterSubmit /> |
| 289 | </SearchFilterBar> |
| 290 | </form> |
| 291 | </div> |
| 292 | |
| 293 | {} |
| 294 | <section style={{ marginTop: "32px" }} aria-label="Bills list"> |
| 295 | <p className="label" style={{ color: "var(--dk-ash)", marginBottom: "24px" }}> |
| 296 | {total.toLocaleString()} bill{total === 1 ? "" : "s"} |
| 297 | {isFiltered ? " match" : ""} · showing {Math.min(shown, bills.length).toLocaleString()} |
| 298 | </p> |
| 299 | |
| 300 | {bills.length === 0 ? ( |
| 301 | <div className="rounded-[6px] border border-line bg-surface p-8" role="status"> |
| 302 | <p className="font-prose" style={{ fontSize: "1.0625rem", color: "var(--dk-ash)" }}> |
| 303 | No bills match this filter in the current record. |
| 304 | </p> |
| 305 | </div> |
| 306 | ) : ( |
| 307 | <> |
| 308 | <BillCards bills={bills.slice(0, shown)} /> |
| 309 | {bills.length > shown ? ( |
| 310 | <div className="mt-4 flex items-center justify-between gap-3"> |
| 311 | <span className="label" style={{ color: "var(--dk-ash-2)" }}> |
| 312 | Showing {shown.toLocaleString()} of {bills.length.toLocaleString()} |
| 313 | </span> |
| 314 | <button |
| 315 | type="button" |
| 316 | onClick={() => setShown((n) => n + 50)} |
| 317 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[8px] border border-line px-4 text-ash underline underline-offset-4 transition-colors hover:border-paper hover:text-paper" |
| 318 | > |
| 319 | Show {Math.min(50, bills.length - shown).toLocaleString()} more |
| 320 | </button> |
| 321 | </div> |
| 322 | ) : null} |
| 323 | </> |
| 324 | )} |
| 325 | </section> |
| 326 | </div> |
| 327 | ); |
| 328 | } |