feat: ZIP → rep lookup, member page redesign, letter improvements

ZIP lookup (GET /api/members/by-zip/{zip}):
- Two-step geocoding: Nominatim (ZIP → lat/lng) then Census TIGERweb
  Legislative identify (lat/lng → congressional district via GEOID)
- Handles at-large states (AK, DE, MT, ND, SD, VT, WY)
- Added rep_lookup health check to admin External API Health panel

congress_api.py fixes:
- parse_member_from_api: normalize state full name → 2-letter code
  (Congress.gov returns "Florida", DB expects "FL")
- parse_member_from_api: read district from top-level data field,
  not current_term (district is not inside the term object)

Celery beat: schedule sync_members daily at 1 AM UTC so chamber,
district, and contact info stay current without manual triggering

Members page redesign: photo avatars, party/state/chamber chips,
phone + website links, ZIP lookup form to find your reps

Draft letter improvements: pass rep_name from ZIP lookup so letter
opens with "Dear Representative Franklin," instead of generic salutation;
add has_document filter to bills list endpoint

UX additions: HelpTip component, How It Works page, "How it works"
sidebar nav link, collections page description copy

Authored-By: Jack Levy
This commit is contained in:
Jack Levy
2026-03-02 15:47:46 -05:00
parent 5bb0c2b8ec
commit 48771287d3
20 changed files with 899 additions and 116 deletions

View File

@@ -1,8 +1,8 @@
"use client";
import { useState } from "react";
import { useState, useEffect } from "react";
import { useSearchParams } from "next/navigation";
import { Search } from "lucide-react";
import { FileText, Search } from "lucide-react";
import { useBills } from "@/lib/hooks/useBills";
import { BillCard } from "@/components/shared/BillCard";
@@ -19,12 +19,22 @@ export default function BillsPage() {
const [q, setQ] = useState(searchParams.get("q") ?? "");
const [chamber, setChamber] = useState(searchParams.get("chamber") ?? "");
const [topic, setTopic] = useState(searchParams.get("topic") ?? "");
const [hasText, setHasText] = useState(false);
const [page, setPage] = useState(1);
// Sync URL params → state so tag/topic links work when already on this page
useEffect(() => {
setQ(searchParams.get("q") ?? "");
setChamber(searchParams.get("chamber") ?? "");
setTopic(searchParams.get("topic") ?? "");
setPage(1);
}, [searchParams]);
const params = {
...(q && { q }),
...(chamber && { chamber }),
...(topic && { topic }),
...(hasText && { has_document: true }),
page,
per_page: 20,
sort: "latest_action_date",
@@ -67,6 +77,18 @@ export default function BillsPage() {
<option value="">All Topics</option>
{TOPICS.slice(1).map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<button
onClick={() => { setHasText((v) => !v); setPage(1); }}
className={`flex items-center gap-1.5 px-3 py-2 text-sm rounded-md border transition-colors ${
hasText
? "bg-primary text-primary-foreground border-primary"
: "bg-card border-border text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
title="Show only bills with published text"
>
<FileText className="w-3.5 h-3.5" />
Has text
</button>
</div>
{/* Results */}

View File

@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Bookmark, Plus, Globe, Lock, Trash2 } from "lucide-react";
import { collectionsAPI } from "@/lib/api";
import { HelpTip } from "@/components/shared/HelpTip";
import type { Collection } from "@/lib/types";
export default function CollectionsPage() {
@@ -49,9 +50,15 @@ export default function CollectionsPage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bookmark className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">My Collections</h1>
<div>
<div className="flex items-center gap-2">
<Bookmark className="w-5 h-5 text-primary" />
<h1 className="text-xl font-bold">My Collections</h1>
</div>
<p className="text-sm text-muted-foreground mt-1">
A collection is a named group of bills you curate like a playlist for legislation.
Share any collection via a link; no account required to view.
</p>
</div>
<button
onClick={() => setShowForm((v) => !v)}
@@ -87,7 +94,7 @@ export default function CollectionsPage() {
className="rounded"
/>
<span>Public collection</span>
<span className="text-xs text-muted-foreground">(share link works either way)</span>
<HelpTip content="Share links work whether or not a collection is public. Marking it public signals it may appear in a future public directory — private collections are invisible to anyone without your link." />
</label>
{formError && <p className="text-xs text-destructive">{formError}</p>}
<div className="flex gap-2">

View File

@@ -0,0 +1,209 @@
import Link from "next/link";
import {
Bell,
Bookmark,
Calendar,
Clock,
FileText,
Heart,
HelpCircle,
Rss,
Shield,
Share2,
Zap,
} from "lucide-react";
function Section({ id, title, icon: Icon, children }: {
id: string;
title: string;
icon: React.ElementType;
children: React.ReactNode;
}) {
return (
<section id={id} className="bg-card border border-border rounded-lg p-6 space-y-4 scroll-mt-6">
<h2 className="text-lg font-semibold flex items-center gap-2">
<Icon className="w-5 h-5 text-primary" />
{title}
</h2>
{children}
</section>
);
}
function Item({ icon: Icon, color, title, children }: {
icon: React.ElementType;
color: string;
title: string;
children: React.ReactNode;
}) {
return (
<div className="flex gap-3">
<div className={`mt-0.5 shrink-0 w-7 h-7 rounded-full flex items-center justify-center ${color}`}>
<Icon className="w-3.5 h-3.5" />
</div>
<div>
<p className="text-sm font-medium">{title}</p>
<p className="text-xs text-muted-foreground mt-0.5 leading-relaxed">{children}</p>
</div>
</div>
);
}
export default function HowItWorksPage() {
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<HelpCircle className="w-5 h-5" /> How it works
</h1>
<p className="text-muted-foreground text-sm mt-1">
A quick guide to PocketVeto&apos;s features.
</p>
{/* Jump links */}
<div className="flex flex-wrap gap-2 mt-3">
{[
{ href: "#follow", label: "Following" },
{ href: "#collections", label: "Collections" },
{ href: "#notifications", label: "Notifications" },
{ href: "#briefs", label: "AI Briefs" },
{ href: "#bills", label: "Bills" },
].map(({ href, label }) => (
<a
key={href}
href={href}
className="text-xs px-2.5 py-1 bg-muted rounded-full hover:bg-accent transition-colors"
>
{label}
</a>
))}
</div>
</div>
{/* Following */}
<Section id="follow" title="Following bills" icon={Heart}>
<p className="text-sm text-muted-foreground">
Follow any bill to track it. PocketVeto checks for changes new text, amendments, status
updates and notifies you through your configured channels. Three modes let you tune the
signal to your interest level.
</p>
<div className="space-y-3">
<Item icon={Heart} color="bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400" title="Following (neutral)">
Alerts on all material changes: new text published, amendments filed, and status updates
like floor votes or committee referrals.
</Item>
<Item icon={Shield} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Pocket Veto">
Alerts only when the bill advances toward becoming law new official text, floor
scheduling, or committee passage. Useful when you oppose a bill and only need to act
if it gains traction.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Pocket Boost">
All the alerts of neutral mode, plus periodic reminders to contact your representative.
Use this when you actively support a bill and want to stay engaged.
</Item>
</div>
<p className="text-xs text-muted-foreground">
You can also follow <strong>members</strong> (get alerts when they sponsor new bills) and{" "}
<strong>topics</strong> (get alerts when new bills matching that topic are briefed).
Member and topic follows use the neutral mode only.
</p>
</Section>
{/* Collections */}
<Section id="collections" title="Collections" icon={Bookmark}>
<p className="text-sm text-muted-foreground">
A collection is a named, curated group of bills like a playlist for legislation. Use
collections to track a policy area, build a watchlist for an advocacy campaign, or share
research with colleagues.
</p>
<div className="space-y-3">
<Item icon={Bookmark} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Creating a collection">
Give it a name (e.g. &ldquo;Healthcare Watch&rdquo;) and add bills from any bill detail
page using the bookmark icon next to the Follow button.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing">
Every collection has a unique share link. Anyone with the link can view the collection
no account required. The link works whether the collection is public or private.
</Item>
</div>
<p className="text-xs text-muted-foreground">
<strong>Public vs. private:</strong> Both have share links. Marking a collection public
signals it may appear in a future public directory; private collections are invisible to
anyone without your link.
</p>
</Section>
{/* Notifications */}
<Section id="notifications" title="Notifications" icon={Bell}>
<p className="text-sm text-muted-foreground">
PocketVeto delivers alerts through two independent channels use either or both.
</p>
<div className="space-y-3">
<Item icon={Bell} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="Push via ntfy">
<a href="https://ntfy.sh" target="_blank" rel="noopener noreferrer" className="text-primary hover:underline">
ntfy
</a>
{" "}is a free, open-source push notification service. Configure a topic URL in{" "}
<Link href="/notifications" className="text-primary hover:underline">Notifications</Link>{" "}
and receive real-time alerts on any device with the ntfy app.
</Item>
<Item icon={Clock} color="bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" title="Quiet hours">
Pause push notifications during set hours (e.g. 10 PM 8 AM). Events that arrive
during quiet hours are queued and sent as a batch when the window ends.
</Item>
<Item icon={Calendar} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Digest mode">
Instead of one push per event, receive a single bundled summary on a daily or weekly
schedule. Your RSS feed is always real-time regardless of this setting.
</Item>
<Item icon={Rss} color="bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400" title="RSS feed">
A private, tokenized RSS feed of all your bill alerts. Subscribe in any RSS reader
(Feedly, NetNewsWire, etc.). Completely independent of ntfy.
</Item>
</div>
</Section>
{/* AI Briefs */}
<Section id="briefs" title="AI Briefs" icon={FileText}>
<p className="text-sm text-muted-foreground">
For bills with published official text, PocketVeto generates a plain-English AI brief.
</p>
<div className="space-y-3">
<Item icon={FileText} color="bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400" title="What's in a brief">
A plain-English summary, key policy points with references to specific bill sections
(§ chips), and a risks section that flags potential unintended consequences or contested
provisions.
</Item>
<Item icon={Share2} color="bg-purple-100 text-purple-600 dark:bg-purple-900/30 dark:text-purple-400" title="Sharing a brief">
Click the share icon in the brief panel to copy a public link. Anyone can read the
brief at that URL no login required.
</Item>
<Item icon={Zap} color="bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400" title="Draft a letter">
Use the Draft Letter panel (below the brief) to generate a personalised letter to your
representative based on the brief&apos;s key points.
</Item>
</div>
<p className="text-xs text-muted-foreground">
Briefs are only generated for bills where GovInfo has published official text. Bills
without text show a &ldquo;No text&rdquo; badge on their card.
</p>
</Section>
{/* Bills */}
<Section id="bills" title="Browsing bills" icon={FileText}>
<p className="text-sm text-muted-foreground">
The <Link href="/bills" className="text-primary hover:underline">Bills</Link> page lists
all tracked legislation. Use the filters to narrow your search.
</p>
<div className="space-y-2 text-xs text-muted-foreground">
<p><strong className="text-foreground">Search</strong> matches bill ID, title, and short title.</p>
<p><strong className="text-foreground">Chamber</strong> House or Senate.</p>
<p><strong className="text-foreground">Topic</strong> AI-tagged policy area (healthcare, defense, etc.).</p>
<p><strong className="text-foreground">Has text</strong> show only bills with published official text available for AI briefing.</p>
</div>
<p className="text-xs text-muted-foreground">
Clicking a topic tag on any bill or following page takes you directly to that filtered
view on the Bills page.
</p>
</Section>
</div>
);
}

View File

@@ -2,30 +2,54 @@
import { useState } from "react";
import Link from "next/link";
import { Search, Heart } from "lucide-react";
import { MapPin, Search, Heart } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useMembers, useMember } from "@/lib/hooks/useMembers";
import { useFollows } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { FollowButton } from "@/components/shared/FollowButton";
import { membersAPI } from "@/lib/api";
import { cn, partyBadgeColor } from "@/lib/utils";
import type { Member } from "@/lib/types";
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
function MemberCard({ member }: { member: Member }) {
return (
<div className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
<div className="flex items-start gap-3 flex-1 min-w-0">
{member.photo_url ? (
<img src={member.photo_url} alt={member.name} className="w-10 h-10 rounded-full object-cover shrink-0 border border-border" />
) : (
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center text-sm font-semibold text-muted-foreground shrink-0">
{member.name[0]}
</div>
)}
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1 flex-wrap">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
{(member.phone || member.official_url) && (
<div className="flex items-center gap-2 mt-1">
{member.phone && (
<a href={`tel:${member.phone.replace(/\D/g, "")}`} className="text-xs text-muted-foreground hover:text-foreground transition-colors">
{member.phone}
</a>
)}
{member.official_url && (
<a href={member.official_url} target="_blank" rel="noopener noreferrer" className="text-xs text-primary hover:underline">
Contact
</a>
)}
</div>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
@@ -33,11 +57,19 @@ function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
);
}
function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
const { data: member } = useMember(bioguideId);
if (!member) return null;
return <MemberCard member={member} />;
}
export default function MembersPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
const [party, setParty] = useState("");
const [page, setPage] = useState(1);
const [zipInput, setZipInput] = useState("");
const [submittedZip, setSubmittedZip] = useState("");
const { data, isLoading } = useMembers({
...(q && { q }), ...(chamber && { chamber }), ...(party && { party }),
@@ -48,6 +80,20 @@ export default function MembersPage() {
const { data: follows } = useFollows();
const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
const isValidZip = /^\d{5}$/.test(submittedZip);
const { data: myReps, isFetching: repsFetching, error: repsError } = useQuery({
queryKey: ["members-by-zip", submittedZip],
queryFn: () => membersAPI.byZip(submittedZip),
enabled: isValidZip,
staleTime: 24 * 60 * 60 * 1000,
retry: false,
});
function handleZipSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmittedZip(zipInput.trim());
}
return (
<div className="space-y-6">
<div>
@@ -55,6 +101,52 @@ export default function MembersPage() {
<p className="text-muted-foreground text-sm mt-1">Browse current Congress members</p>
</div>
{/* Zip lookup */}
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<p className="text-sm font-medium flex items-center gap-2">
<MapPin className="w-4 h-4 text-primary" />
Find your representatives
</p>
<form onSubmit={handleZipSubmit} className="flex gap-2">
<input
type="text"
value={zipInput}
onChange={(e) => setZipInput(e.target.value)}
placeholder="Enter ZIP code"
maxLength={5}
className="px-3 py-2 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary w-40"
/>
<button
type="submit"
disabled={!/^\d{5}$/.test(zipInput.trim())}
className="px-4 py-2 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
Find
</button>
</form>
{repsFetching && (
<p className="text-sm text-muted-foreground">Looking up representatives</p>
)}
{repsError && (
<p className="text-sm text-destructive">Could not look up representatives. Check your ZIP and try again.</p>
)}
{isValidZip && !repsFetching && myReps && myReps.length === 0 && (
<p className="text-sm text-muted-foreground">No representatives found for {submittedZip}.</p>
)}
{myReps && myReps.length > 0 && (
<div className="space-y-2 pt-1">
<p className="text-xs text-muted-foreground">Representatives for ZIP {submittedZip}</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{myReps.map((rep) => (
<MemberCard key={rep.bioguide_id} member={rep} />
))}
</div>
</div>
)}
</div>
{/* Filters */}
<div className="flex gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
@@ -103,23 +195,7 @@ export default function MembersPage() {
<div className="text-sm text-muted-foreground">{data?.total ?? 0} members</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{data?.items?.map((member) => (
<div key={member.bioguide_id} className="bg-card border border-border rounded-lg p-4 flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<Link href={`/members/${member.bioguide_id}`} className="font-medium text-sm hover:text-primary transition-colors">
{member.name}
</Link>
<div className="flex items-center gap-1.5 mt-1">
{member.party && (
<span className={cn("px-1.5 py-0.5 rounded text-xs font-medium", partyBadgeColor(member.party))}>
{member.party}
</span>
)}
{member.state && <span className="text-xs text-muted-foreground">{member.state}</span>}
{member.chamber && <span className="text-xs text-muted-foreground">{member.chamber}</span>}
</div>
</div>
<FollowButton type="member" value={member.bioguide_id} />
</div>
<MemberCard key={member.bioguide_id} member={member} />
))}
</div>
{data && data.pages > 1 && (

View File

@@ -8,6 +8,7 @@ import {
} from "lucide-react";
import Link from "next/link";
import { notificationsAPI, type NotificationTestResult } from "@/lib/api";
import { useFollows } from "@/lib/hooks/useFollows";
import type { NotificationEvent } from "@/lib/types";
const AUTH_METHODS = [
@@ -50,6 +51,11 @@ export default function NotificationsPage() {
staleTime: 60 * 1000,
});
const { data: follows = [] } = useFollows();
const directlyFollowedBillIds = new Set(
follows.filter((f) => f.follow_type === "bill").map((f) => f.follow_value)
);
const update = useMutation({
mutationFn: (data: Parameters<typeof notificationsAPI.updateSettings>[0]) =>
notificationsAPI.updateSettings(data),
@@ -497,59 +503,106 @@ export default function NotificationsPage() {
</section>
{/* Notification History */}
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Recent Alerts
</h2>
<p className="text-xs text-muted-foreground mt-1">Last 50 notification events for your account.</p>
</div>
</div>
{(() => {
const directEvents = history.filter((e: NotificationEvent) => {
const src = (e.payload as Record<string, unknown>)?.source as string | undefined;
if (src === "topic_follow") return false;
if (src === "bill_follow" || src === "member_follow") return true;
// Legacy events (no source field): treat as direct if bill is followed
return directlyFollowedBillIds.has(e.bill_id);
});
const topicEvents = history.filter((e: NotificationEvent) => {
const src = (e.payload as Record<string, unknown>)?.source as string | undefined;
if (src === "topic_follow") return true;
if (src) return false;
return !directlyFollowedBillIds.has(e.bill_id);
});
{historyLoading ? (
<p className="text-sm text-muted-foreground">Loading history</p>
) : history.length === 0 ? (
<p className="text-sm text-muted-foreground">
No events yet. Follow some bills and check back after the next poll.
</p>
) : (
<div className="divide-y divide-border">
{history.map((event: NotificationEvent) => {
const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" };
const Icon = meta.icon;
const payload = event.payload ?? {};
return (
<div key={event.id} className="flex items-start gap-3 py-3">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span>
{payload.bill_label && (
<Link href={`/bills/${event.bill_id}`}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
{payload.bill_label}
</Link>
)}
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
{payload.bill_title && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{payload.bill_title}</p>
)}
{payload.brief_summary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{payload.brief_summary}</p>
)}
</div>
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
const EventRow = ({ event, showDispatch }: { event: NotificationEvent; showDispatch: boolean }) => {
const meta = EVENT_META[event.event_type] ?? { label: "Update", icon: Bell, color: "text-muted-foreground" };
const Icon = meta.icon;
const p = (event.payload ?? {}) as Record<string, unknown>;
const billLabel = p.bill_label as string | undefined;
const billTitle = p.bill_title as string | undefined;
const briefSummary = p.brief_summary as string | undefined;
return (
<div className="flex items-start gap-3 py-3">
<Icon className={`w-4 h-4 mt-0.5 shrink-0 ${meta.color}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium">{meta.label}</span>
{billLabel && (
<Link href={`/bills/${event.bill_id}`}
className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded hover:text-primary transition-colors">
{billLabel}
</Link>
)}
<span className="text-xs text-muted-foreground ml-auto">{timeAgo(event.created_at)}</span>
</div>
);
})}
</div>
)}
</section>
{billTitle && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{billTitle}</p>
)}
{briefSummary && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{briefSummary}</p>
)}
</div>
{showDispatch && (
<span className={`text-xs shrink-0 ${event.dispatched_at ? "text-green-500" : "text-amber-500"}`}
title={event.dispatched_at ? `Sent ${timeAgo(event.dispatched_at)}` : "Pending dispatch"}>
{event.dispatched_at ? "✓" : "⏳"}
</span>
)}
</div>
);
};
return (
<>
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4" /> Recent Alerts
</h2>
<p className="text-xs text-muted-foreground mt-1">
Notifications for bills and members you directly follow. Last 50 events.
</p>
</div>
{historyLoading ? (
<p className="text-sm text-muted-foreground">Loading history</p>
) : directEvents.length === 0 ? (
<p className="text-sm text-muted-foreground">
No alerts yet. Follow some bills and check back after the next poll.
</p>
) : (
<div className="divide-y divide-border">
{directEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch />
))}
</div>
)}
</section>
{topicEvents.length > 0 && (
<section className="bg-card border border-border rounded-lg p-6 space-y-4">
<div>
<h2 className="font-semibold flex items-center gap-2">
<Bell className="w-4 h-4 text-muted-foreground" /> Based on your topic follows
</h2>
<p className="text-xs text-muted-foreground mt-1">
Bills matching topics you follow that have had recent activity.
These are delivered as notifications follow a bill directly to change its alert mode.
</p>
</div>
<div className="divide-y divide-border">
{topicEvents.map((event: NotificationEvent) => (
<EventRow key={event.id} event={event} showDispatch={false} />
))}
</div>
</section>
)}
</>
);
})()}
</div>
);
}

View File

@@ -605,6 +605,7 @@ export default function SettingsPage() {
{ key: "govinfo", label: "GovInfo API" },
{ key: "newsapi", label: "NewsAPI.org" },
{ key: "google_news", label: "Google News RSS" },
{ key: "rep_lookup", label: "Rep Lookup (Nominatim + TIGERweb)" },
].map(({ key, label }) => {
const r = healthData[key];
if (!r) return null;

View File

@@ -1,9 +1,10 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { ChevronDown, ChevronRight, Copy, Check, Loader2, PenLine } from "lucide-react";
import type { BriefSchema, CitedPoint } from "@/lib/types";
import { billsAPI } from "@/lib/api";
import { useQuery } from "@tanstack/react-query";
import { ChevronDown, ChevronRight, Copy, Check, ExternalLink, Loader2, Phone, PenLine } from "lucide-react";
import type { BriefSchema, CitedPoint, Member } from "@/lib/types";
import { billsAPI, membersAPI } from "@/lib/api";
import { useIsFollowing } from "@/lib/hooks/useFollows";
interface DraftLetterPanelProps {
@@ -27,6 +28,15 @@ function chamberToRecipient(chamber?: string): "house" | "senate" {
return chamber?.toLowerCase() === "senate" ? "senate" : "house";
}
function formatRepName(member: Member): string {
// DB stores name as "Last, First" — convert to "First Last" for the letter
if (member.name.includes(", ")) {
const [last, first] = member.name.split(", ");
return `${first} ${last}`;
}
return member.name;
}
export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelProps) {
const [open, setOpen] = useState(false);
const existing = useIsFollowing("bill", billId);
@@ -53,6 +63,27 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Zip → rep lookup (debounced via React Query enabled flag)
const zipTrimmed = zipCode.trim();
const isValidZip = /^\d{5}$/.test(zipTrimmed);
const { data: zipReps, isFetching: zipFetching } = useQuery({
queryKey: ["members-by-zip", zipTrimmed],
queryFn: () => membersAPI.byZip(zipTrimmed),
enabled: isValidZip,
staleTime: 24 * 60 * 60 * 1000,
retry: false,
});
// Filter reps to match the bill's chamber
const relevantReps = zipReps?.filter((m) =>
recipient === "senate"
? m.chamber === "Senate"
: m.chamber === "House of Representatives"
) ?? [];
// Use first matched rep's name for the letter salutation
const repName = relevantReps.length > 0 ? formatRepName(relevantReps[0]) : undefined;
const keyPoints = brief.key_points ?? [];
const risks = brief.risks ?? [];
const allPoints = [
@@ -96,6 +127,7 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
selected_points: selectedPoints,
include_citations: includeCitations,
zip_code: zipCode.trim() || undefined,
rep_name: repName,
});
setDraft(result.draft);
} catch (err: unknown) {
@@ -253,20 +285,23 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
</div>
{/* Options row */}
<div className="flex flex-wrap items-center gap-4">
<div className="space-y-0.5">
<input
type="text"
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
placeholder="ZIP code"
maxLength={10}
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground"
/>
<div className="flex flex-wrap items-start gap-4">
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
type="text"
value={zipCode}
onChange={(e) => setZipCode(e.target.value)}
placeholder="ZIP code"
maxLength={10}
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground w-28 placeholder:text-muted-foreground"
/>
{zipFetching && <Loader2 className="w-3.5 h-3.5 animate-spin text-muted-foreground" />}
</div>
<p className="text-[10px] text-muted-foreground">optional · not stored</p>
</div>
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-muted-foreground mt-1.5">
<input
type="checkbox"
checked={includeCitations}
@@ -277,6 +312,72 @@ export function DraftLetterPanel({ billId, brief, chamber }: DraftLetterPanelPro
</label>
</div>
{/* Rep lookup results */}
{isValidZip && !zipFetching && relevantReps.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs font-medium text-muted-foreground">
Your {recipient === "senate" ? "senators" : "representative"}
</p>
{relevantReps.map((rep) => (
<div
key={rep.bioguide_id}
className="flex items-center gap-3 bg-muted/40 border border-border rounded-md px-3 py-2"
>
{rep.photo_url && (
<img
src={rep.photo_url}
alt={rep.name}
className="w-8 h-8 rounded-full object-cover shrink-0 border border-border"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-xs font-medium">{formatRepName(rep)}</p>
{rep.party && (
<p className="text-[10px] text-muted-foreground">{rep.party} · {rep.state}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{rep.phone && (
<a
href={`tel:${rep.phone.replace(/\D/g, "")}`}
className="flex items-center gap-1 text-[10px] text-muted-foreground hover:text-foreground transition-colors"
title="Office phone"
>
<Phone className="w-3 h-3" />
{rep.phone}
</a>
)}
{rep.official_url && (
<a
href={rep.official_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-[10px] text-primary hover:underline"
title="Contact form"
>
<ExternalLink className="w-3 h-3" />
Contact
</a>
)}
</div>
</div>
))}
{repName && (
<p className="text-[10px] text-muted-foreground">
Letter will be addressed to{" "}
{recipient === "senate" ? "Senator" : "Representative"} {repName}.
</p>
)}
</div>
)}
{isValidZip && !zipFetching && relevantReps.length === 0 && zipReps !== undefined && (
<p className="text-[10px] text-amber-500">
Could not find your {recipient === "senate" ? "senators" : "representative"} for that ZIP.
The letter will use a generic salutation.
</p>
)}
{/* Generate button */}
<button
onClick={handleGenerate}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { TrendingUp, Calendar, User, FileText, FileClock, FileX } from "lucide-react";
import { TrendingUp, Calendar, User, FileText, FileClock, FileX, Tag } from "lucide-react";
import { Bill } from "@/lib/types";
import { billLabel, chamberBadgeColor, cn, formatDate, partyBadgeColor, trendColor } from "@/lib/utils";
import { FollowButton } from "./FollowButton";
@@ -28,12 +28,15 @@ export function BillCard({ bill, compact = false }: BillCardProps) {
</span>
)}
{tags.map((tag) => (
<span
<Link
key={tag}
className="text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground"
href={`/bills?topic=${tag}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-xs px-1.5 py-0.5 rounded-full bg-accent text-accent-foreground hover:bg-accent/70 transition-colors"
>
<Tag className="w-2.5 h-2.5" />
{tag}
</span>
</Link>
))}
</div>

View File

@@ -0,0 +1,46 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { HelpCircle } from "lucide-react";
import { cn } from "@/lib/utils";
interface HelpTipProps {
content: string;
className?: string;
}
export function HelpTip({ content, className }: HelpTipProps) {
const [visible, setVisible] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!visible) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setVisible(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [visible]);
return (
<div className={cn("relative inline-flex items-center", className)} ref={ref}>
<button
type="button"
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onClick={() => setVisible((v) => !v)}
aria-label="Help"
className="text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="w-3.5 h-3.5" />
</button>
{visible && (
<div className="absolute left-5 top-0 z-50 w-64 bg-popover border border-border rounded-md shadow-lg p-3 text-xs text-muted-foreground leading-relaxed">
{content}
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Bookmark,
HelpCircle,
LayoutDashboard,
FileText,
Users,
@@ -28,6 +29,7 @@ const NAV = [
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
{ href: "/collections", label: "Collections", icon: Bookmark, adminOnly: false, requiresAuth: true },
{ href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
{ href: "/how-it-works", label: "How it works", icon: HelpCircle, adminOnly: false, requiresAuth: false },
{ href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
];

View File

@@ -85,6 +85,7 @@ export const billsAPI = {
selected_points: string[];
include_citations: boolean;
zip_code?: string;
rep_name?: string;
}) =>
apiClient.post<{ draft: string }>(`/api/bills/${id}/draft-letter`, body).then((r) => r.data),
};
@@ -132,6 +133,8 @@ export const membersAPI = {
apiClient.get<PaginatedResponse<Member>>("/api/members", { params }).then((r) => r.data),
get: (id: string) =>
apiClient.get<Member>(`/api/members/${id}`).then((r) => r.data),
byZip: (zip: string) =>
apiClient.get<Member[]>(`/api/members/by-zip/${zip}`).then((r) => r.data),
getBills: (id: string, params?: Record<string, unknown>) =>
apiClient.get<PaginatedResponse<Bill>>(`/api/members/${id}/bills`, { params }).then((r) => r.data),
getTrend: (id: string, days?: number) =>