| 1 | "use client"; |
| 2 | |
| 3 | import { useEffect, useState } from "react"; |
| 4 | import { useSearchParams } from "next/navigation"; |
| 5 | import Link from "next/link"; |
| 6 | import { Card } from "@/components/ui"; |
| 7 | import { SourceLink } from "@/components/source-link"; |
| 8 | import { MemberAvatar } from "@/components/member-avatar"; |
| 9 | import { SearchBox } from "@/components/search-box"; |
| 10 | import { ShowMore } from "@/components/show-more"; |
| 11 | import { congressLabel, formatBillNumber } from "@/lib/format"; |
| 12 | import type { BillListItem, MemberSummary, ExecutiveAction, StateCoverage } from "@/lib/feed"; |
| 13 | |
| 14 | function formatDistrict(chamber: string, district: number | null): string { |
| 15 | if (chamber === "senate") return ""; |
| 16 | if (district === null || district <= 0) return " · At-Large"; |
| 17 | return ` · Dist. ${district}`; |
| 18 | } |
| 19 | |
| 20 | type SearchResultAward = { |
| 21 | id: string; |
| 22 | agency: string; |
| 23 | recipient: string; |
| 24 | amount: number; |
| 25 | sourceUrl: string; |
| 26 | }; |
| 27 | |
| 28 | type SearchResults = { |
| 29 | bills: BillListItem[]; |
| 30 | members: MemberSummary[]; |
| 31 | executive: ExecutiveAction[]; |
| 32 | awards: SearchResultAward[]; |
| 33 | states: StateCoverage[]; |
| 34 | }; |
| 35 | |
| 36 | type ResultType = "all" | "bills" | "members" | "executive" | "awards" | "states"; |
| 37 | |
| 38 | const TYPE_LABELS: Record<ResultType, string> = { |
| 39 | all: "All types", |
| 40 | bills: "Bills", |
| 41 | members: "Members", |
| 42 | executive: "Federal Register", |
| 43 | awards: "Federal awards", |
| 44 | states: "States", |
| 45 | }; |
| 46 | |
| 47 | function buildHref(q: string, type: ResultType, sort: string): string { |
| 48 | const params = new URLSearchParams(); |
| 49 | if (q) params.set("q", q); |
| 50 | if (type && type !== "all") params.set("type", type); |
| 51 | if (sort) params.set("sort", sort); |
| 52 | const qs = params.toString(); |
| 53 | return `/search${qs ? `?${qs}` : ""}`; |
| 54 | } |
| 55 | |
| 56 | function applySortToResults(results: SearchResults, sort: string): SearchResults { |
| 57 | if (!sort || sort === "relevance") return results; |
| 58 | if (sort === "alpha") { |
| 59 | return { |
| 60 | ...results, |
| 61 | bills: [...results.bills].sort((a, b) => a.title.localeCompare(b.title)), |
| 62 | members: [...results.members].sort((a, b) => a.name.localeCompare(b.name)), |
| 63 | executive: [...results.executive].sort((a, b) => a.title.localeCompare(b.title)), |
| 64 | }; |
| 65 | } |
| 66 | if (sort === "amount") { |
| 67 | return { |
| 68 | ...results, |
| 69 | awards: [...results.awards].sort((a, b) => b.amount - a.amount), |
| 70 | }; |
| 71 | } |
| 72 | return results; |
| 73 | } |
| 74 | |
| 75 | function ResultGroup({ |
| 76 | title, |
| 77 | count, |
| 78 | children, |
| 79 | }: { |
| 80 | title: string; |
| 81 | count: number; |
| 82 | children: React.ReactNode; |
| 83 | }) { |
| 84 | return ( |
| 85 | <div> |
| 86 | <p className="label mb-3"> |
| 87 | {title} · {count} |
| 88 | </p> |
| 89 | <Card>{children}</Card> |
| 90 | </div> |
| 91 | ); |
| 92 | } |
| 93 | |
| 94 | function SearchSkeleton() { |
| 95 | return ( |
| 96 | <div className="mt-8 space-y-4"> |
| 97 | <div className="h-14 w-full animate-pulse rounded-sm bg-graphite" /> |
| 98 | <div className="mt-6 h-8 w-64 animate-pulse rounded-sm bg-graphite" /> |
| 99 | <div className="mt-6 space-y-3"> |
| 100 | {Array.from({ length: 8 }).map((_, i) => ( |
| 101 | <div key={i} className="h-12 w-full animate-pulse rounded-sm bg-graphite" /> |
| 102 | ))} |
| 103 | </div> |
| 104 | </div> |
| 105 | ); |
| 106 | } |
| 107 | |
| 108 | export function SearchBrowser() { |
| 109 | const sp = useSearchParams(); |
| 110 | const q = (sp.get("q") ?? "").trim(); |
| 111 | const typeParam = (sp.get("type") ?? "all") as ResultType; |
| 112 | const sortParam = sp.get("sort") ?? ""; |
| 113 | |
| 114 | const [rawResults, setRawResults] = useState<SearchResults>({ |
| 115 | bills: [], |
| 116 | members: [], |
| 117 | executive: [], |
| 118 | awards: [], |
| 119 | states: [], |
| 120 | }); |
| 121 | const [loading, setLoading] = useState(false); |
| 122 | |
| 123 | useEffect(() => { |
| 124 | if (!q) { |
| 125 | setRawResults({ bills: [], members: [], executive: [], awards: [], states: [] }); |
| 126 | return; |
| 127 | } |
| 128 | setLoading(true); |
| 129 | fetch(`/api/search?q=${encodeURIComponent(q)}`) |
| 130 | .then((r) => r.json()) |
| 131 | .then((data) => { |
| 132 | setRawResults({ |
| 133 | bills: data.bills ?? [], |
| 134 | members: data.members ?? [], |
| 135 | executive: data.executive ?? [], |
| 136 | awards: data.awards ?? [], |
| 137 | states: data.states ?? [], |
| 138 | }); |
| 139 | }) |
| 140 | .catch(() => { |
| 141 | setRawResults({ bills: [], members: [], executive: [], awards: [], states: [] }); |
| 142 | }) |
| 143 | .finally(() => setLoading(false)); |
| 144 | }, [q]); |
| 145 | |
| 146 | const results = applySortToResults(rawResults, sortParam); |
| 147 | |
| 148 | const total = |
| 149 | results.bills.length + |
| 150 | results.members.length + |
| 151 | results.executive.length + |
| 152 | results.awards.length + |
| 153 | results.states.length; |
| 154 | |
| 155 | const activeType: ResultType = |
| 156 | ["all", "bills", "members", "executive", "awards", "states"].includes(typeParam) |
| 157 | ? typeParam |
| 158 | : "all"; |
| 159 | |
| 160 | const showBills = activeType === "all" || activeType === "bills"; |
| 161 | const showMembers = activeType === "all" || activeType === "members"; |
| 162 | const showExec = activeType === "all" || activeType === "executive"; |
| 163 | const showAwards = activeType === "all" || activeType === "awards"; |
| 164 | const showStates = activeType === "all" || activeType === "states"; |
| 165 | |
| 166 | const visibleTotal = |
| 167 | (showBills ? results.bills.length : 0) + |
| 168 | (showMembers ? results.members.length : 0) + |
| 169 | (showExec ? results.executive.length : 0) + |
| 170 | (showAwards ? results.awards.length : 0) + |
| 171 | (showStates ? results.states.length : 0); |
| 172 | |
| 173 | return ( |
| 174 | <div> |
| 175 | <SearchBox initialValue={q} /> |
| 176 | |
| 177 | {!q ? ( |
| 178 | <div className="mt-6"> |
| 179 | <p className="label mb-3 text-ash-2">Try a starting point</p> |
| 180 | <div className="flex flex-wrap gap-2"> |
| 181 | {["tax", "defense", "California", "appropriations", "Texas", "immigration"].map( |
| 182 | (chip) => ( |
| 183 | <Link |
| 184 | key={chip} |
| 185 | href={`/search?q=${encodeURIComponent(chip)}`} |
| 186 | className="focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border border-line bg-surface px-4 text-ash underline-offset-4 transition-colors hover:bg-graphite hover:text-paper" |
| 187 | > |
| 188 | {chip} |
| 189 | </Link> |
| 190 | ) |
| 191 | )} |
| 192 | </div> |
| 193 | </div> |
| 194 | ) : null} |
| 195 | |
| 196 | {loading ? ( |
| 197 | <div className="mt-10"> |
| 198 | <SearchSkeleton /> |
| 199 | </div> |
| 200 | ) : q ? ( |
| 201 | <div className="mt-10"> |
| 202 | {} |
| 203 | <div className="mb-6 flex flex-wrap items-center justify-between gap-3"> |
| 204 | {} |
| 205 | <div className="flex flex-wrap gap-2" role="group" aria-label="Filter results by type"> |
| 206 | {(["all", "bills", "members", "executive", "awards", "states"] as ResultType[]).map( |
| 207 | (type) => { |
| 208 | const count = |
| 209 | type === "all" |
| 210 | ? total |
| 211 | : type === "bills" |
| 212 | ? results.bills.length |
| 213 | : type === "members" |
| 214 | ? results.members.length |
| 215 | : type === "executive" |
| 216 | ? results.executive.length |
| 217 | : type === "awards" |
| 218 | ? results.awards.length |
| 219 | : results.states.length; |
| 220 | if (type !== "all" && count === 0) return null; |
| 221 | const active = activeType === type; |
| 222 | return ( |
| 223 | <Link |
| 224 | key={type} |
| 225 | href={buildHref(q, type, sortParam)} |
| 226 | aria-current={active ? "true" : undefined} |
| 227 | className={[ |
| 228 | "focus-ring label inline-flex min-h-[44px] items-center rounded-[2px] border px-3 transition-colors", |
| 229 | active |
| 230 | ? "border-paper bg-surface text-paper" |
| 231 | : "border-line bg-surface text-ash hover:bg-graphite hover:text-paper", |
| 232 | ].join(" ")} |
| 233 | > |
| 234 | {TYPE_LABELS[type]} |
| 235 | {count > 0 ? ( |
| 236 | <span className="ml-1.5 tabular-nums text-ash-2">{count}</span> |
| 237 | ) : null} |
| 238 | </Link> |
| 239 | ); |
| 240 | } |
| 241 | )} |
| 242 | </div> |
| 243 | |
| 244 | {} |
| 245 | <form method="get" action="/search" className="flex items-center gap-2"> |
| 246 | <input type="hidden" name="q" value={q} /> |
| 247 | {activeType !== "all" && ( |
| 248 | <input type="hidden" name="type" value={activeType} /> |
| 249 | )} |
| 250 | <label htmlFor="search-sort" className="label text-ash-2"> |
| 251 | Sort |
| 252 | </label> |
| 253 | <select |
| 254 | id="search-sort" |
| 255 | name="sort" |
| 256 | defaultValue={sortParam} |
| 257 | aria-label="Sort search results" |
| 258 | className="focus-ring min-h-[44px] rounded-[3px] border border-line bg-surface px-3 py-2 font-mono text-[13px] text-paper" |
| 259 | > |
| 260 | <option value="">Relevance / newest</option> |
| 261 | <option value="alpha">Alphabetical (A-Z)</option> |
| 262 | <option value="amount">By amount (awards)</option> |
| 263 | </select> |
| 264 | <button |
| 265 | type="submit" |
| 266 | className="focus-ring label min-h-[44px] rounded-[3px] border border-line bg-surface px-3 text-paper hover:bg-graphite" |
| 267 | > |
| 268 | Apply |
| 269 | </button> |
| 270 | </form> |
| 271 | </div> |
| 272 | |
| 273 | <p className="label mb-6 text-ash"> |
| 274 | {visibleTotal} result{visibleTotal === 1 ? "" : "s"} for “{q}” |
| 275 | {activeType !== "all" ? ` · ${TYPE_LABELS[activeType]}` : ""} |
| 276 | </p> |
| 277 | |
| 278 | {visibleTotal === 0 ? ( |
| 279 | <Card className="p-8 text-center"> |
| 280 | <p className="display text-2xl text-paper">No matches on record.</p> |
| 281 | <p className="prose-lead mx-auto mt-3 max-w-md text-ash"> |
| 282 | Try a state code, a bill number, an agency, or a member's name. |
| 283 | </p> |
| 284 | </Card> |
| 285 | ) : ( |
| 286 | <div className="space-y-10"> |
| 287 | {showMembers && results.members.length > 0 ? ( |
| 288 | <ResultGroup title="Members" count={results.members.length}> |
| 289 | <ShowMore |
| 290 | pageSize={50} |
| 291 | items={results.members.map((m) => ( |
| 292 | <Link |
| 293 | key={m.id} |
| 294 | href={m.href} |
| 295 | className="focus-ring group flex items-center gap-3 border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60" |
| 296 | > |
| 297 | <MemberAvatar name={m.name} photoUrl={m.photoUrl} size="sm" /> |
| 298 | <span className="min-w-0"> |
| 299 | <span className="block font-mono text-[13px] text-paper group-hover:underline"> |
| 300 | {m.name} |
| 301 | </span> |
| 302 | <span className="label text-ash-2"> |
| 303 | {m.chamber === "senate" ? "Senate" : "House"} · {m.state} |
| 304 | {formatDistrict(m.chamber, m.district)} · {m.party} |
| 305 | </span> |
| 306 | </span> |
| 307 | </Link> |
| 308 | ))} |
| 309 | /> |
| 310 | </ResultGroup> |
| 311 | ) : null} |
| 312 | |
| 313 | {showBills && results.bills.length > 0 ? ( |
| 314 | <ResultGroup title="Bills" count={results.bills.length}> |
| 315 | <ShowMore |
| 316 | pageSize={50} |
| 317 | items={results.bills.map((b) => { |
| 318 | const cong = congressLabel(b.congress); |
| 319 | return ( |
| 320 | <Link |
| 321 | key={b.id} |
| 322 | href={b.href} |
| 323 | className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60" |
| 324 | > |
| 325 | <p className="font-mono text-[13px] text-paper group-hover:underline"> |
| 326 | {formatBillNumber(b.number)} |
| 327 | {cong ? ` · ${cong}` : ""} |
| 328 | </p> |
| 329 | <p className="font-mono text-[12px] text-ash">{b.title}</p> |
| 330 | <p className="label mt-1 text-ash-2">{b.policyArea}</p> |
| 331 | </Link> |
| 332 | ); |
| 333 | })} |
| 334 | /> |
| 335 | </ResultGroup> |
| 336 | ) : null} |
| 337 | |
| 338 | {showExec && results.executive.length > 0 ? ( |
| 339 | <ResultGroup title="Federal Register" count={results.executive.length}> |
| 340 | <ShowMore |
| 341 | pageSize={50} |
| 342 | items={results.executive.map((e) => ( |
| 343 | <div |
| 344 | key={e.id} |
| 345 | className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0" |
| 346 | > |
| 347 | <div className="min-w-0"> |
| 348 | <p className="font-mono text-[13px] text-paper">{e.title}</p> |
| 349 | <p className="label mt-1 text-ash-2"> |
| 350 | {e.agency} |
| 351 | {e.date ? ` · ${e.date.slice(0, 10)}` : ""} |
| 352 | </p> |
| 353 | </div> |
| 354 | <SourceLink url={e.sourceUrl} label="FR" /> |
| 355 | </div> |
| 356 | ))} |
| 357 | /> |
| 358 | </ResultGroup> |
| 359 | ) : null} |
| 360 | |
| 361 | {showAwards && results.awards.length > 0 ? ( |
| 362 | <ResultGroup title="Federal awards" count={results.awards.length}> |
| 363 | <ShowMore |
| 364 | pageSize={50} |
| 365 | items={results.awards.map((a) => ( |
| 366 | <div |
| 367 | key={a.id} |
| 368 | className="flex items-start justify-between gap-3 border-b border-line p-3 last:border-0" |
| 369 | > |
| 370 | <div className="min-w-0"> |
| 371 | <p className="font-mono text-[13px] text-paper">{a.agency}</p> |
| 372 | <p className="label mt-1 text-ash-2">{a.recipient}</p> |
| 373 | </div> |
| 374 | <SourceLink url={a.sourceUrl} label="USAspending" /> |
| 375 | </div> |
| 376 | ))} |
| 377 | /> |
| 378 | </ResultGroup> |
| 379 | ) : null} |
| 380 | |
| 381 | {showStates && results.states.length > 0 ? ( |
| 382 | <ResultGroup title="States" count={results.states.length}> |
| 383 | <ShowMore |
| 384 | pageSize={50} |
| 385 | items={results.states.map((s) => ( |
| 386 | <Link |
| 387 | key={s.code} |
| 388 | href={`/states/${s.code}`} |
| 389 | className="focus-ring group block border-b border-line p-3 transition-colors last:border-0 hover:bg-graphite/60" |
| 390 | > |
| 391 | <p className="font-mono text-[13px] text-paper group-hover:underline"> |
| 392 | {s.code} · {s.name} |
| 393 | </p> |
| 394 | </Link> |
| 395 | ))} |
| 396 | /> |
| 397 | </ResultGroup> |
| 398 | ) : null} |
| 399 | </div> |
| 400 | )} |
| 401 | </div> |
| 402 | ) : null} |
| 403 | </div> |
| 404 | ); |
| 405 | } |