● Zion Boggan
repos/Docket/components/search-browser.tsx
zionboggan.com ↗
405 lines · tsx
History for this file →
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 &ldquo;{q}&rdquo;
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&apos;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
}