● Zion Boggan
repos/Docket/components/bills-browser.tsx
zionboggan.com ↗
328 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 { 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
}