feat: PocketVeto v1.0.0 — initial public release

Self-hosted US Congress monitoring platform with AI policy briefs,
bill/member/topic follows, ntfy + RSS + email notifications,
alignment scoring, collections, and draft-letter generator.

Authored by: Jack Levy
This commit is contained in:
Jack Levy
2026-03-15 01:35:01 -04:00
commit 4c86a5b9ca
150 changed files with 19859 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { AlertTriangle, CheckCircle, Clock, Cpu, ExternalLink } from "lucide-react";
import { BriefSchema, CitedPoint } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface AIBriefCardProps {
brief?: BriefSchema | null;
}
function isCited(p: string | CitedPoint): p is CitedPoint {
return typeof p === "object" && p !== null && "text" in p;
}
interface CitedItemProps {
point: string | CitedPoint;
icon: React.ReactNode;
govinfo_url?: string;
openKey: string;
activeKey: string | null;
setActiveKey: (key: string | null) => void;
}
function CitedItem({ point, icon, govinfo_url, openKey, activeKey, setActiveKey }: CitedItemProps) {
const cited = isCited(point);
const isOpen = activeKey === openKey;
return (
<li className="text-sm">
<div className="flex items-start gap-2">
<span className="mt-0.5 shrink-0">{icon}</span>
<div className="flex-1 min-w-0 space-y-1">
<div className="flex items-start gap-2">
<span className="flex-1">{cited ? point.text : point}</span>
{cited && point.label === "inference" && (
<span
title="This point is an analytical interpretation, not a literal statement from the bill text"
className="shrink-0 text-[10px] px-1.5 py-0.5 rounded border border-border text-muted-foreground font-sans leading-none mt-0.5"
>
Inferred
</span>
)}
</div>
{cited && (
<button
onClick={() => setActiveKey(isOpen ? null : openKey)}
title={isOpen ? "Hide source" : "View source"}
className={`text-left text-xs px-1.5 py-0.5 rounded font-mono leading-snug transition-colors ${
isOpen
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
§ {point.citation}
</button>
)}
</div>
</div>
{cited && isOpen && (
<div className="mt-2 ml-5 rounded-md border border-border bg-muted/40 p-3 space-y-2">
<blockquote className="text-xs text-muted-foreground italic leading-relaxed border-l-2 border-primary pl-3">
"{point.quote}"
</blockquote>
{govinfo_url && (
<a
href={govinfo_url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-primary hover:underline"
>
View source <ExternalLink className="w-3 h-3" />
</a>
)}
</div>
)}
</li>
);
}
export function AIBriefCard({ brief }: AIBriefCardProps) {
const [activeKey, setActiveKey] = useState<string | null>(null);
if (!brief) {
return (
<div className="bg-card border border-border rounded-lg p-6">
<div className="flex items-center gap-2 mb-3">
<Cpu className="w-4 h-4 text-muted-foreground" />
<h2 className="font-semibold">AI Analysis</h2>
</div>
<p className="text-sm text-muted-foreground italic">
Analysis not yet generated. It will appear once the bill text has been processed.
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg p-6 space-y-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Cpu className="w-4 h-4 text-primary" />
<h2 className="font-semibold">AI Analysis</h2>
</div>
<span className="text-xs text-muted-foreground">
{brief.llm_provider}/{brief.llm_model} · {formatDate(brief.created_at)}
</span>
</div>
{brief.summary && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Summary</h3>
<p className="text-sm leading-relaxed whitespace-pre-line">{brief.summary}</p>
</div>
)}
{brief.key_points && brief.key_points.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Key Points</h3>
<ul className="space-y-2">
{brief.key_points.map((point, i) => (
<CitedItem
key={i}
point={point}
icon={<CheckCircle className="w-3.5 h-3.5 text-green-500" />}
govinfo_url={brief.govinfo_url}
openKey={`kp-${i}`}
activeKey={activeKey}
setActiveKey={setActiveKey}
/>
))}
</ul>
</div>
)}
{brief.risks && brief.risks.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Risks & Concerns</h3>
<ul className="space-y-2">
{brief.risks.map((risk, i) => (
<CitedItem
key={i}
point={risk}
icon={<AlertTriangle className="w-3.5 h-3.5 text-yellow-500" />}
govinfo_url={brief.govinfo_url}
openKey={`risk-${i}`}
activeKey={activeKey}
setActiveKey={setActiveKey}
/>
))}
</ul>
</div>
)}
{brief.deadlines && brief.deadlines.length > 0 && (
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">Deadlines</h3>
<ul className="space-y-1.5">
{brief.deadlines.map((d, i) => (
<li key={i} className="flex items-start gap-2 text-sm">
<Clock className="w-3.5 h-3.5 mt-0.5 text-blue-500 shrink-0" />
<span>
{d.date ? <strong>{formatDate(d.date)}: </strong> : ""}
{d.description}
</span>
</li>
))}
</ul>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { Clock } from "lucide-react";
import { BillAction } from "@/lib/types";
import { formatDate } from "@/lib/utils";
interface ActionTimelineProps {
actions: BillAction[];
latestActionDate?: string;
latestActionText?: string;
}
export function ActionTimeline({ actions, latestActionDate, latestActionText }: ActionTimelineProps) {
const hasActions = actions && actions.length > 0;
const hasFallback = !hasActions && latestActionText;
if (!hasActions && !hasFallback) {
return (
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="font-semibold mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
Action History
</h2>
<p className="text-sm text-muted-foreground italic">No actions recorded yet.</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg p-6">
<h2 className="font-semibold mb-4 flex items-center gap-2">
<Clock className="w-4 h-4" />
Action History
{hasActions && (
<span className="text-xs text-muted-foreground font-normal">({actions.length})</span>
)}
</h2>
<div className="relative">
<div className="absolute left-2 top-0 bottom-0 w-px bg-border" />
<ul className="space-y-4 pl-7">
{hasActions ? (
actions.map((action) => (
<li key={action.id} className="relative">
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-primary/60 border-2 border-background" />
<div className="text-xs text-muted-foreground mb-0.5">
{formatDate(action.action_date)}
{action.chamber && ` · ${action.chamber}`}
</div>
<p className="text-sm leading-snug">{action.action_text}</p>
</li>
))
) : (
<li className="relative">
<div className="absolute -left-5 top-1.5 w-2 h-2 rounded-full bg-muted-foreground/40 border-2 border-background" />
<div className="text-xs text-muted-foreground mb-0.5">
{formatDate(latestActionDate)}
<span className="ml-1.5 italic">· latest known action</span>
</div>
<p className="text-sm leading-snug">{latestActionText}</p>
<p className="text-xs text-muted-foreground mt-1 italic">
Full history loads in the background refresh to see all actions.
</p>
</li>
)}
</ul>
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client";
import { useState } from "react";
import { Check, ChevronDown, ChevronRight, RefreshCw, Share2 } from "lucide-react";
import { BriefSchema } from "@/lib/types";
import { AIBriefCard } from "@/components/bills/AIBriefCard";
import { formatDate } from "@/lib/utils";
interface BriefPanelProps {
briefs?: BriefSchema[] | null;
}
const TYPE_LABEL: Record<string, string> = {
amendment: "AMENDMENT",
full: "FULL",
};
function typeBadge(briefType?: string) {
const label = TYPE_LABEL[briefType ?? ""] ?? (briefType?.toUpperCase() ?? "BRIEF");
const isAmendment = briefType === "amendment";
return (
<span
className={`text-xs font-mono px-1.5 py-0.5 rounded ${
isAmendment
? "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
: "bg-muted text-muted-foreground"
}`}
>
{label}
</span>
);
}
export function BriefPanel({ briefs }: BriefPanelProps) {
const [historyOpen, setHistoryOpen] = useState(false);
const [expandedId, setExpandedId] = useState<number | null>(null);
const [copied, setCopied] = useState(false);
function copyShareLink(brief: BriefSchema) {
if (!brief.share_token) return;
navigator.clipboard.writeText(`${window.location.origin}/share/brief/${brief.share_token}`);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
if (!briefs || briefs.length === 0) {
return <AIBriefCard brief={null} />;
}
const latest = briefs[0];
const history = briefs.slice(1);
const isAmendment = latest.brief_type === "amendment";
return (
<div className="space-y-3">
{/* "What Changed" badge row */}
{isAmendment && (
<div className="flex items-center gap-2 px-1">
<RefreshCw className="w-3.5 h-3.5 text-amber-500" />
<span className="text-sm font-semibold text-amber-600 dark:text-amber-400">
What Changed
</span>
<span className="text-xs text-muted-foreground">·</span>
<span className="text-xs text-muted-foreground">{formatDate(latest.created_at)}</span>
</div>
)}
{/* Share button row */}
{latest.share_token && (
<div className="flex justify-end px-1">
<button
onClick={() => copyShareLink(latest)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
title="Copy shareable link to this brief"
>
{copied ? (
<><Check className="w-3.5 h-3.5 text-green-500" /> Link copied!</>
) : (
<><Share2 className="w-3.5 h-3.5" /> Share brief</>
)}
</button>
</div>
)}
{/* Latest brief */}
<AIBriefCard brief={latest} />
{/* Version history (only when there are older briefs) */}
{history.length > 0 && (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<button
onClick={() => setHistoryOpen((o) => !o)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors text-left"
>
{historyOpen ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
Version History ({history.length} {history.length === 1 ? "version" : "versions"})
</button>
{historyOpen && (
<div className="border-t border-border divide-y divide-border">
{history.map((brief) => (
<div key={brief.id}>
<button
onClick={() =>
setExpandedId((id) => (id === brief.id ? null : brief.id))
}
className="w-full flex items-center gap-3 px-4 py-2.5 text-left hover:bg-accent/40 transition-colors"
>
<span className="text-xs text-muted-foreground w-20 shrink-0">
{formatDate(brief.created_at)}
</span>
{typeBadge(brief.brief_type)}
<span className="text-xs text-muted-foreground truncate flex-1">
{brief.summary?.slice(0, 120) ?? "No summary"}
{(brief.summary?.length ?? 0) > 120 ? "…" : ""}
</span>
{expandedId === brief.id ? (
<ChevronDown className="w-3 h-3 text-muted-foreground shrink-0" />
) : (
<ChevronRight className="w-3 h-3 text-muted-foreground shrink-0" />
)}
</button>
{expandedId === brief.id && (
<div className="px-4 pb-4">
<AIBriefCard brief={brief} />
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,143 @@
"use client";
import { useRef, useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { Bookmark, Check } from "lucide-react";
import { collectionsAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
import type { Collection } from "@/lib/types";
interface CollectionPickerProps {
billId: string;
}
export function CollectionPicker({ billId }: CollectionPickerProps) {
const token = useAuthStore((s) => s.token);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const qc = useQueryClient();
useEffect(() => {
if (!open) return;
function onClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onClickOutside);
return () => document.removeEventListener("mousedown", onClickOutside);
}, [open]);
const { data: collections } = useQuery({
queryKey: ["collections"],
queryFn: collectionsAPI.list,
enabled: !!token,
});
const addMutation = useMutation({
mutationFn: (id: number) => collectionsAPI.addBill(id, billId),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: ["collections"] });
qc.invalidateQueries({ queryKey: ["collection", id] });
},
});
const removeMutation = useMutation({
mutationFn: (id: number) => collectionsAPI.removeBill(id, billId),
onSuccess: (_, id) => {
qc.invalidateQueries({ queryKey: ["collections"] });
qc.invalidateQueries({ queryKey: ["collection", id] });
},
});
if (!token) return null;
// Determine which collections contain this bill
// We check each collection's bill_count proxy by re-fetching detail... but since the list
// endpoint doesn't return bill_ids, we use a lightweight approach: track via optimistic state.
// The collection detail page has the bill list; for the picker we just check each collection.
// To avoid N+1, we'll use a separate query to get the user's collection memberships for this bill.
// For simplicity, we use the collections list and compare via a bill-membership query.
return (
<div ref={ref} className="relative">
<button
onClick={() => setOpen((v) => !v)}
title="Add to collection"
className={`p-1.5 rounded-md transition-colors ${
open
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
}`}
>
<Bookmark className="w-4 h-4" />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 z-20 w-56 bg-card border border-border rounded-lg shadow-lg overflow-hidden">
{!collections || collections.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground">
No collections yet.
</div>
) : (
<ul>
{collections.map((c: Collection) => (
<CollectionPickerRow
key={c.id}
collection={c}
billId={billId}
onAdd={() => addMutation.mutate(c.id)}
onRemove={() => removeMutation.mutate(c.id)}
/>
))}
</ul>
)}
<div className="border-t border-border px-3 py-2">
<Link
href="/collections"
onClick={() => setOpen(false)}
className="text-xs text-primary hover:underline"
>
New collection
</Link>
</div>
</div>
)}
</div>
);
}
function CollectionPickerRow({
collection,
billId,
onAdd,
onRemove,
}: {
collection: Collection;
billId: string;
onAdd: () => void;
onRemove: () => void;
}) {
// Fetch detail to know if this bill is in the collection
const { data: detail } = useQuery({
queryKey: ["collection", collection.id],
queryFn: () => collectionsAPI.get(collection.id),
});
const inCollection = detail?.bills.some((b) => b.bill_id === billId) ?? false;
return (
<li>
<button
onClick={inCollection ? onRemove : onAdd}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-accent transition-colors text-left"
>
<span className="w-4 h-4 shrink-0 flex items-center justify-center">
{inCollection && <Check className="w-3.5 h-3.5 text-primary" />}
</span>
<span className="truncate flex-1">{collection.name}</span>
</button>
</li>
);
}

View File

@@ -0,0 +1,434 @@
"use client";
import { useState, useEffect, useRef } from "react";
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 {
billId: string;
brief: BriefSchema;
chamber?: string;
}
type Stance = "yes" | "no" | null;
type Tone = "short" | "polite" | "firm";
function pointText(p: string | CitedPoint): string {
return typeof p === "string" ? p : p.text;
}
function pointKey(p: string | CitedPoint, i: number): string {
return `${i}-${typeof p === "string" ? p.slice(0, 40) : p.text.slice(0, 40)}`;
}
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);
const [stance, setStance] = useState<Stance>(null);
const prevModeRef = useRef<string | undefined>(undefined);
// Keep stance in sync with follow mode changes (including unfollow → null)
useEffect(() => {
const newMode = existing?.follow_mode;
if (newMode === prevModeRef.current) return;
prevModeRef.current = newMode;
if (newMode === "pocket_boost") setStance("yes");
else if (newMode === "pocket_veto") setStance("no");
else setStance(null);
}, [existing?.follow_mode]);
const recipient = chamberToRecipient(chamber);
const [tone, setTone] = useState<Tone>("polite");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [includeCitations, setIncludeCitations] = useState(true);
const [zipCode, setZipCode] = useState("");
const [loading, setLoading] = useState(false);
const [draft, setDraft] = useState<string | null>(null);
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 = [
...keyPoints.map((p, i) => ({ group: "key" as const, index: i, text: pointText(p), raw: p })),
...risks.map((p, i) => ({ group: "risk" as const, index: keyPoints.length + i, text: pointText(p), raw: p })),
];
function togglePoint(globalIndex: number) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(globalIndex)) {
next.delete(globalIndex);
} else if (next.size < 3) {
next.add(globalIndex);
}
return next;
});
}
async function handleGenerate() {
if (selected.size === 0 || stance === null) return;
const selectedPoints = allPoints
.filter((p) => selected.has(p.index))
.map((p) => {
if (includeCitations && typeof p.raw !== "string" && p.raw.citation) {
return `${p.text} (${p.raw.citation})`;
}
return p.text;
});
setLoading(true);
setError(null);
setDraft(null);
try {
const result = await billsAPI.generateDraft(billId, {
stance,
recipient,
tone,
selected_points: selectedPoints,
include_citations: includeCitations,
zip_code: zipCode.trim() || undefined,
rep_name: repName,
});
setDraft(result.draft);
} catch (err: unknown) {
const detail =
err &&
typeof err === "object" &&
"response" in err &&
err.response &&
typeof err.response === "object" &&
"data" in err.response &&
err.response.data &&
typeof err.response.data === "object" &&
"detail" in err.response.data
? String((err.response.data as { detail: string }).detail)
: "Failed to generate letter. Please try again.";
setError(detail);
} finally {
setLoading(false);
}
}
async function handleCopy() {
if (!draft) return;
await navigator.clipboard.writeText(draft);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<button
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-2 px-4 py-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors text-left"
>
{open ? (
<ChevronDown className="w-3.5 h-3.5 shrink-0" />
) : (
<ChevronRight className="w-3.5 h-3.5 shrink-0" />
)}
<PenLine className="w-3.5 h-3.5 shrink-0" />
Draft a letter to your {recipient === "senate" ? "senator" : "representative"}
</button>
{open && (
<div className="border-t border-border px-4 py-4 space-y-4">
{/* Stance + Tone */}
<div className="flex flex-wrap gap-4">
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Stance</p>
<div className="flex rounded-md overflow-hidden border border-border text-xs">
{(["yes", "no"] as ("yes" | "no")[]).map((s) => (
<button
key={s}
onClick={() => setStance(s)}
className={`px-3 py-1.5 font-medium transition-colors ${
stance === s
? s === "yes"
? "bg-green-600 text-white"
: "bg-red-600 text-white"
: "bg-background text-muted-foreground hover:bg-accent/50"
}`}
>
{s === "yes" ? "Support (Vote YES)" : "Oppose (Vote NO)"}
</button>
))}
</div>
{stance === null && (
<p className="text-[10px] text-amber-500 mt-1">Select a position to continue</p>
)}
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">Tone</p>
<select
value={tone}
onChange={(e) => setTone(e.target.value as Tone)}
className="text-xs bg-background border border-border rounded px-2 py-1.5 text-foreground"
>
<option value="short">Short</option>
<option value="polite">Polite</option>
<option value="firm">Firm</option>
</select>
</div>
</div>
{/* Point selector */}
<div className="space-y-1">
<p className="text-xs font-medium text-muted-foreground">
Select up to 3 points to include
{selected.size > 0 && (
<span className="ml-1 text-muted-foreground">({selected.size}/3)</span>
)}
</p>
<div className="border border-border rounded-md divide-y divide-border">
{keyPoints.length > 0 && (
<>
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
Key Points
</p>
{keyPoints.map((p, i) => {
const globalIndex = i;
const isChecked = selected.has(globalIndex);
const isDisabled = !isChecked && selected.size >= 3;
return (
<label
key={pointKey(p, i)}
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
onChange={() => togglePoint(globalIndex)}
className="mt-0.5 shrink-0"
/>
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
</label>
);
})}
</>
)}
{risks.length > 0 && (
<>
<p className="px-3 py-1.5 text-xs font-semibold text-muted-foreground bg-muted/40">
Concerns
</p>
{risks.map((p, i) => {
const globalIndex = keyPoints.length + i;
const isChecked = selected.has(globalIndex);
const isDisabled = !isChecked && selected.size >= 3;
return (
<label
key={pointKey(p, keyPoints.length + i)}
className={`flex items-start gap-2.5 px-3 py-2 cursor-pointer transition-colors ${
isDisabled ? "opacity-40 cursor-not-allowed" : "hover:bg-accent/40"
}`}
>
<input
type="checkbox"
checked={isChecked}
disabled={isDisabled}
onChange={() => togglePoint(globalIndex)}
className="mt-0.5 shrink-0"
/>
<span className="text-xs text-foreground leading-snug">{pointText(p)}</span>
</label>
);
})}
</>
)}
</div>
</div>
{/* Options row */}
<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 mt-1.5">
<input
type="checkbox"
checked={includeCitations}
onChange={(e) => setIncludeCitations(e.target.checked)}
className="shrink-0"
/>
Include citations
</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}
disabled={loading || selected.size === 0 || stance === null}
className="flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground text-xs font-medium rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{loading && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{loading ? "Generating…" : "Generate letter"}
</button>
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* Draft output */}
{draft && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground italic">Edit before sending</p>
<button
onClick={handleCopy}
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{copied ? (
<>
<Check className="w-3.5 h-3.5 text-green-500" />
<span className="text-green-500">Copied!</span>
</>
) : (
<>
<Copy className="w-3.5 h-3.5" />
Copy
</>
)}
</button>
</div>
<textarea
readOnly
value={draft}
rows={10}
className="w-full text-xs bg-muted/30 border border-border rounded-md px-3 py-2 text-foreground resize-y font-sans leading-relaxed"
/>
</div>
)}
{/* Footer */}
<p className="text-[10px] text-muted-foreground border-t border-border pt-3">
Based only on the bill&apos;s cited text · We don&apos;t store your location
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
import { ExternalLink, Newspaper } from "lucide-react";
import { formatDate } from "@/lib/utils";
interface ArticleLike {
id: number;
source?: string;
headline?: string;
url?: string;
published_at?: string;
}
interface NewsPanelProps {
articles?: ArticleLike[];
}
export function NewsPanel({ articles }: NewsPanelProps) {
return (
<div className="bg-card border border-border rounded-lg p-4">
<h3 className="font-semibold text-sm flex items-center gap-2 mb-3">
<Newspaper className="w-4 h-4" />
Related News
{articles && articles.length > 0 && (
<span className="text-xs text-muted-foreground font-normal">({articles.length})</span>
)}
</h3>
{!articles || articles.length === 0 ? (
<p className="text-xs text-muted-foreground italic">No news articles found yet.</p>
) : (
<ul className="space-y-3">
{articles.slice(0, 8).map((article) => (
<li key={article.id} className="group">
<a
href={article.url}
target="_blank"
rel="noopener noreferrer"
className="block hover:text-primary transition-colors"
>
<p className="text-xs font-medium line-clamp-2 leading-snug group-hover:underline">
{article.headline}
<ExternalLink className="w-3 h-3 inline ml-1 opacity-50" />
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{article.source} · {formatDate(article.published_at)}
</p>
</a>
</li>
))}
</ul>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Pin, PinOff, Trash2, Save } from "lucide-react";
import { notesAPI } from "@/lib/api";
import { useAuthStore } from "@/stores/authStore";
interface NotesPanelProps {
billId: string;
}
export function NotesPanel({ billId }: NotesPanelProps) {
const token = useAuthStore((s) => s.token);
const qc = useQueryClient();
const queryKey = ["note", billId];
const { data: note, isLoading } = useQuery({
queryKey,
queryFn: () => notesAPI.get(billId),
enabled: !!token,
retry: false, // 404 = no note; don't retry
throwOnError: false,
});
const [content, setContent] = useState("");
const [pinned, setPinned] = useState(false);
const [saved, setSaved] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Sync form from loaded note
useEffect(() => {
if (note) {
setContent(note.content);
setPinned(note.pinned);
}
}, [note]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [content]);
const upsert = useMutation({
mutationFn: () => notesAPI.upsert(billId, content, pinned),
onSuccess: (updated) => {
qc.setQueryData(queryKey, updated);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
},
});
const remove = useMutation({
mutationFn: () => notesAPI.delete(billId),
onSuccess: () => {
qc.removeQueries({ queryKey });
setContent("");
setPinned(false);
},
});
if (!token) return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">Sign in to add private notes.</p>
</div>
);
if (isLoading) return null;
const hasNote = !!note;
const isDirty = hasNote
? content !== note.content || pinned !== note.pinned
: content.trim().length > 0;
return (
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
<textarea
ref={textareaRef}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Add a private note about this bill…"
rows={3}
className="w-full text-sm bg-background border border-border rounded-md px-3 py-2 focus:outline-none focus:ring-1 focus:ring-primary resize-none overflow-hidden"
/>
<div className="flex items-center justify-between gap-3">
{/* Left: pin toggle + delete */}
<div className="flex items-center gap-3">
<button
onClick={() => setPinned((v) => !v)}
title={pinned ? "Unpin note" : "Pin above tabs"}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-md border transition-colors ${
pinned
? "border-primary text-primary bg-primary/10"
: "border-border text-muted-foreground hover:text-foreground hover:bg-accent"
}`}
>
{pinned ? <Pin className="w-3 h-3" /> : <PinOff className="w-3 h-3" />}
{pinned ? "Pinned" : "Pin"}
</button>
{hasNote && (
<button
onClick={() => remove.mutate()}
disabled={remove.isPending}
title="Delete note"
className="p-1.5 rounded-md text-muted-foreground hover:text-destructive hover:bg-accent transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* Right: save */}
<button
onClick={() => upsert.mutate()}
disabled={!content.trim() || upsert.isPending || (!isDirty && !saved)}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 transition-colors"
>
<Save className="w-3 h-3" />
{saved ? "Saved!" : upsert.isPending ? "Saving…" : "Save"}
</button>
</div>
<p className="text-xs text-muted-foreground">Private only visible to you.</p>
</div>
);
}

View File

@@ -0,0 +1,134 @@
"use client";
import { TrendingUp, Newspaper, Radio } from "lucide-react";
import {
ComposedChart,
Line,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
CartesianGrid,
Legend,
} from "recharts";
import { TrendScore, MemberTrendScore } from "@/lib/types";
type AnyTrendScore = TrendScore | MemberTrendScore;
interface TrendChartProps {
data?: AnyTrendScore[];
title?: string;
}
function ScoreBadge({ label, value, icon }: { label: string; value: number | string; icon: React.ReactNode }) {
return (
<div className="flex flex-col items-center gap-0.5">
<div className="text-muted-foreground">{icon}</div>
<span className="text-xs font-semibold tabular-nums">{value}</span>
<span className="text-[10px] text-muted-foreground">{label}</span>
</div>
);
}
export function TrendChart({ data, title = "Public Interest" }: TrendChartProps) {
const chartData = data?.map((d) => ({
date: new Date(d.score_date + "T00:00:00").toLocaleDateString("en-US", { month: "short", day: "numeric" }),
score: Math.round(d.composite_score),
newsapi: d.newsapi_count,
gnews: d.gnews_count,
gtrends: Math.round(d.gtrends_score),
})) ?? [];
const latest = data?.[data.length - 1];
return (
<div className="bg-card border border-border rounded-lg p-4 space-y-4">
<div className="flex items-center justify-between">
<h3 className="font-semibold text-sm flex items-center gap-2">
<TrendingUp className="w-4 h-4" />
{title}
</h3>
{latest !== undefined && (
<span className="text-2xl font-bold tabular-nums">{Math.round(latest.composite_score)}</span>
)}
</div>
{/* Signal breakdown badges */}
{latest && (
<div className="flex justify-around border border-border rounded-md p-2 bg-muted/30">
<ScoreBadge
label="NewsAPI"
value={latest.newsapi_count}
icon={<Newspaper className="w-3 h-3" />}
/>
<div className="w-px bg-border" />
<ScoreBadge
label="Google News"
value={latest.gnews_count}
icon={<Radio className="w-3 h-3" />}
/>
<div className="w-px bg-border" />
<ScoreBadge
label="Trends"
value={`${Math.round(latest.gtrends_score)}/100`}
icon={<TrendingUp className="w-3 h-3" />}
/>
</div>
)}
{chartData.length === 0 ? (
<p className="text-xs text-muted-foreground italic text-center py-8">
Interest data not yet available. Check back after the nightly scoring run.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<ComposedChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" />
<XAxis
dataKey="date"
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fontSize: 10, fill: "hsl(var(--muted-foreground))" }}
tickLine={false}
/>
<Tooltip
contentStyle={{
backgroundColor: "hsl(var(--card))",
border: "1px solid hsl(var(--border))",
borderRadius: "6px",
fontSize: "12px",
}}
formatter={(value: number, name: string) => {
const labels: Record<string, string> = {
score: "Composite",
newsapi: "NewsAPI articles",
gnews: "Google News articles",
gtrends: "Google Trends",
};
return [value, labels[name] ?? name];
}}
/>
<Bar dataKey="gnews" fill="hsl(var(--muted-foreground))" opacity={0.3} name="gnews" radius={[2, 2, 0, 0]} />
<Bar dataKey="newsapi" fill="hsl(var(--primary))" opacity={0.3} name="newsapi" radius={[2, 2, 0, 0]} />
<Line
type="monotone"
dataKey="score"
stroke="hsl(var(--primary))"
strokeWidth={2}
dot={false}
name="score"
/>
</ComposedChart>
</ResponsiveContainer>
)}
<p className="text-[10px] text-muted-foreground">
Composite 0100 · NewsAPI articles (max 40 pts) + Google News volume (max 30 pts) + Google Trends score (max 30 pts)
</p>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ListChecks, ChevronDown, ChevronUp } from "lucide-react";
import { billsAPI, followsAPI } from "@/lib/api";
import { cn, formatDate, partyBadgeColor } from "@/lib/utils";
import type { BillVote, MemberVotePosition } from "@/lib/types";
interface VotePanelProps {
billId: string;
alwaysRender?: boolean;
}
export function VotePanel({ billId, alwaysRender = false }: VotePanelProps) {
const [expanded, setExpanded] = useState(true);
const { data: votes, isLoading } = useQuery({
queryKey: ["votes", billId],
queryFn: () => billsAPI.getVotes(billId),
staleTime: 5 * 60 * 1000,
});
const { data: follows } = useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
retry: false,
throwOnError: false,
});
const followedMemberIds = new Set(
(follows || [])
.filter((f) => f.follow_type === "member")
.map((f) => f.follow_value)
);
if (isLoading || !votes || votes.length === 0) {
if (!alwaysRender) return null;
return (
<div className="bg-card border border-border rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground">
{isLoading ? "Checking for roll-call votes…" : "No roll-call votes have been recorded for this bill."}
</p>
</div>
);
}
return (
<div className="bg-card border border-border rounded-lg overflow-hidden">
<button
onClick={() => setExpanded((e) => !e)}
className="w-full flex items-center justify-between p-4 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-2">
<ListChecks className="w-4 h-4 text-muted-foreground" />
<span className="font-medium text-sm">
Roll-Call Votes{" "}
<span className="text-muted-foreground font-normal">({votes.length})</span>
</span>
</div>
{expanded ? (
<ChevronUp className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
)}
</button>
{expanded && (
<div className="divide-y divide-border">
{votes.map((vote) => (
<VoteRow key={vote.id} vote={vote} followedMemberIds={followedMemberIds} />
))}
</div>
)}
</div>
);
}
function VoteRow({
vote,
followedMemberIds,
}: {
vote: BillVote;
followedMemberIds: Set<string>;
}) {
const [showPositions, setShowPositions] = useState(false);
const total = (vote.yeas ?? 0) + (vote.nays ?? 0) + (vote.not_voting ?? 0);
const yeaPct = total > 0 ? ((vote.yeas ?? 0) / total) * 100 : 0;
const nayPct = total > 0 ? ((vote.nays ?? 0) / total) * 100 : 0;
const resultLower = (vote.result ?? "").toLowerCase();
const passed =
resultLower.includes("pass") ||
resultLower.includes("agreed") ||
resultLower.includes("adopted") ||
resultLower.includes("enacted");
const followedPositions: MemberVotePosition[] = vote.positions.filter(
(p) => p.bioguide_id && followedMemberIds.has(p.bioguide_id)
);
return (
<div className="p-4 space-y-3">
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="space-y-1 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{vote.result && (
<span
className={cn(
"text-xs px-2 py-0.5 rounded font-medium shrink-0",
passed
? "bg-emerald-100 text-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-400"
: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400"
)}
>
{vote.result}
</span>
)}
<span className="text-xs text-muted-foreground">
{vote.chamber} Roll #{vote.roll_number}
{vote.vote_date && ` · ${formatDate(vote.vote_date)}`}
</span>
</div>
{vote.question && (
<p className="text-sm font-medium">{vote.question}</p>
)}
</div>
{vote.source_url && (
<a
href={vote.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary transition-colors shrink-0"
>
Source
</a>
)}
</div>
{/* Yea / Nay bar */}
{total > 0 && (
<div className="space-y-1.5">
<div className="flex h-2 rounded overflow-hidden bg-muted gap-0.5">
<div
className="bg-emerald-500 transition-all"
style={{ width: `${yeaPct}%` }}
title={`Yea: ${vote.yeas}`}
/>
<div
className="bg-red-500 transition-all"
style={{ width: `${nayPct}%` }}
title={`Nay: ${vote.nays}`}
/>
</div>
<div className="flex items-center gap-4 text-xs">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">
{vote.yeas ?? "—"} Yea
</span>
<span className="text-red-600 dark:text-red-400 font-medium">
{vote.nays ?? "—"} Nay
</span>
{(vote.not_voting ?? 0) > 0 && (
<span className="text-muted-foreground">{vote.not_voting} Not Voting</span>
)}
</div>
</div>
)}
{/* Followed member positions */}
{followedPositions.length > 0 && (
<div>
<button
onClick={() => setShowPositions((e) => !e)}
className="text-xs text-primary hover:underline"
>
{showPositions ? "Hide" : "Show"} {followedPositions.length} followed member
{followedPositions.length !== 1 ? "s'" : "'"} vote
{followedPositions.length !== 1 ? "s" : ""}
</button>
{showPositions && (
<div className="mt-2 space-y-1.5">
{followedPositions.map((p, i) => (
<div key={p.bioguide_id ?? i} className="flex items-center gap-2 text-xs">
<span
className={cn(
"w-2 h-2 rounded-full shrink-0",
p.position === "Yea"
? "bg-emerald-500"
: p.position === "Nay"
? "bg-red-500"
: "bg-muted-foreground"
)}
/>
<span className="text-muted-foreground shrink-0">
{vote.chamber === "Senate" ? "Sen." : "Rep."}
</span>
<span className="font-medium">{p.member_name}</span>
{p.party && (
<span className={cn("px-1 py-0.5 rounded font-medium shrink-0", partyBadgeColor(p.party))}>
{p.party}
</span>
)}
{p.state && <span className="text-muted-foreground">{p.state}</span>}
<span
className={cn(
"ml-auto font-medium shrink-0",
p.position === "Yea"
? "text-emerald-600 dark:text-emerald-400"
: p.position === "Nay"
? "text-red-600 dark:text-red-400"
: "text-muted-foreground"
)}
>
{p.position}
</span>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader";
const NO_SHELL_PATHS = ["/login", "/register", "/share"];
const AUTH_REQUIRED = ["/following", "/notifications", "/collections"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
const pathname = usePathname();
const token = useAuthStore((s) => s.token);
// Zustand persist hydrates asynchronously — wait for it before rendering
const [hydrated, setHydrated] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
setHydrated(true);
}, []);
useEffect(() => {
if (!hydrated) return;
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) {
router.replace("/login");
}
}, [hydrated, token, pathname, router]);
if (!hydrated) return null;
// Login/register/share pages render without the app shell
if (NO_SHELL_PATHS.some((p) => pathname.startsWith(p))) {
return <>{children}</>;
}
// Auth-required pages: blank while redirecting
const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
if (!token && needsAuth) return null;
// Authenticated or guest browsing: render the full app shell
return (
<div className="flex h-screen bg-background">
{/* Desktop sidebar — hidden on mobile */}
<div className="hidden md:flex">
<Sidebar />
</div>
{/* Mobile slide-in drawer */}
{drawerOpen && (
<div className="fixed inset-0 z-50 md:hidden">
<div className="absolute inset-0 bg-black/50" onClick={() => setDrawerOpen(false)} />
<div className="absolute left-0 top-0 bottom-0">
<Sidebar onClose={() => setDrawerOpen(false)} />
</div>
</div>
)}
{/* Content column */}
<div className="flex-1 flex flex-col min-h-0">
<MobileHeader onMenuClick={() => setDrawerOpen(true)} />
<main className="flex-1 overflow-auto">
<div className="container mx-auto px-4 md:px-6 py-6 max-w-7xl">
{children}
</div>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import * as Dialog from "@radix-ui/react-dialog";
import { X } from "lucide-react";
interface AuthModalProps {
open: boolean;
onClose: () => void;
}
export function AuthModal({ open, onClose }: AuthModalProps) {
return (
<Dialog.Root open={open} onOpenChange={onClose}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2 w-full max-w-sm bg-card border border-border rounded-lg shadow-lg p-6 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95">
<Dialog.Title className="text-base font-semibold">
Sign in to follow bills
</Dialog.Title>
<Dialog.Description className="mt-2 text-sm text-muted-foreground">
Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts.
</Dialog.Description>
<div className="flex gap-3 mt-4">
<Link href="/register" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors">
Create account
</Link>
<Link href="/login" onClick={onClose} className="flex-1 px-4 py-2 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors">
Sign in
</Link>
</div>
<Dialog.Close className="absolute right-4 top-4 p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors">
<X className="w-4 h-4" />
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}

View File

@@ -0,0 +1,103 @@
import Link from "next/link";
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";
import { TOPIC_LABEL, TOPIC_TAGS } from "@/lib/topics";
interface BillCardProps {
bill: Bill;
compact?: boolean;
}
export function BillCard({ bill, compact = false }: BillCardProps) {
const label = billLabel(bill.bill_type, bill.bill_number);
const score = bill.latest_trend?.composite_score;
const tags = (bill.latest_brief?.topic_tags || []).filter((t) => TOPIC_TAGS.has(t)).slice(0, 3);
return (
<div className="bg-card border border-border rounded-lg p-4 hover:border-primary/30 transition-colors">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
<span className="text-xs font-mono font-semibold text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
{label}
</span>
{bill.chamber && (
<span className={cn("text-xs px-1.5 py-0.5 rounded font-medium", chamberBadgeColor(bill.chamber))}>
{bill.chamber}
</span>
)}
{tags.map((tag) => (
<Link
key={tag}
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" />
{TOPIC_LABEL[tag] ?? tag}
</Link>
))}
</div>
<Link href={`/bills/${bill.bill_id}`}>
<h3 className="text-sm font-medium leading-snug hover:text-primary transition-colors line-clamp-2">
{bill.short_title || bill.title || "Untitled Bill"}
</h3>
</Link>
{!compact && bill.sponsor && (
<div className="flex items-center gap-1.5 mt-1.5 text-xs text-muted-foreground">
<User className="w-3 h-3" />
<Link href={`/members/${bill.sponsor.bioguide_id}`} className="hover:text-foreground transition-colors">
{bill.sponsor.name}
</Link>
{bill.sponsor.party && (
<span className={cn("px-1 py-0.5 rounded text-xs font-medium", partyBadgeColor(bill.sponsor.party))}>
{bill.sponsor.party}
</span>
)}
{bill.sponsor.state && (
<span>{bill.sponsor.state}</span>
)}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<FollowButton type="bill" value={bill.bill_id} supportsModes />
{score !== undefined && score > 0 && (
<div className={cn("flex items-center gap-1 text-xs font-medium", trendColor(score))}>
<TrendingUp className="w-3 h-3" />
{Math.round(score)}
</div>
)}
{bill.latest_brief ? (
<div className="flex items-center gap-1 text-xs text-emerald-600 dark:text-emerald-400" title="Analysis available">
<FileText className="w-3 h-3" />
<span>Brief</span>
</div>
) : bill.has_document ? (
<div className="flex items-center gap-1 text-xs text-amber-500" title="Text retrieved, analysis pending">
<FileClock className="w-3 h-3" />
<span>Pending</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground/50" title="No bill text published">
<FileX className="w-3 h-3" />
<span>No text</span>
</div>
)}
</div>
</div>
{!compact && bill.latest_action_text && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2 border-t border-border pt-2">
<Calendar className="w-3 h-3 inline mr-1" />
{formatDate(bill.latest_action_date)} {bill.latest_action_text}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useRef, useEffect, useState } from "react";
import { Heart, Shield, Zap, ChevronDown } from "lucide-react";
import { useAddFollow, useIsFollowing, useRemoveFollow, useUpdateFollowMode } from "@/lib/hooks/useFollows";
import { useAuthStore } from "@/stores/authStore";
import { AuthModal } from "./AuthModal";
import { cn } from "@/lib/utils";
const MODES = {
neutral: {
label: "Following",
icon: Heart,
color: "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400",
},
pocket_veto: {
label: "Pocket Veto",
icon: Shield,
color: "bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400",
},
pocket_boost: {
label: "Pocket Boost",
icon: Zap,
color: "bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400",
},
} as const;
type FollowMode = keyof typeof MODES;
interface FollowButtonProps {
type: "bill" | "member" | "topic";
value: string;
label?: string;
supportsModes?: boolean;
}
export function FollowButton({ type, value, label, supportsModes = false }: FollowButtonProps) {
const existing = useIsFollowing(type, value);
const add = useAddFollow();
const remove = useRemoveFollow();
const updateMode = useUpdateFollowMode();
const token = useAuthStore((s) => s.token);
const [open, setOpen] = useState(false);
const [showAuthModal, setShowAuthModal] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
function requireAuth(action: () => void) {
if (!token) { setShowAuthModal(true); return; }
action();
}
const isFollowing = !!existing;
const currentMode: FollowMode = (existing?.follow_mode as FollowMode) ?? "neutral";
const isPending = add.isPending || remove.isPending || updateMode.isPending;
// Close dropdown on outside click
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
// Simple toggle for non-bill follows
if (!supportsModes) {
const handleClick = () => {
requireAuth(() => {
if (isFollowing && existing) {
remove.mutate(existing.id);
} else {
add.mutate({ type, value });
}
});
};
return (
<>
<button
onClick={handleClick}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
isFollowing
? "bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400"
: "bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
)}
>
<Heart className={cn("w-3.5 h-3.5", isFollowing && "fill-current")} />
{isFollowing ? "Unfollow" : label || "Follow"}
</button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}
// Mode-aware follow button for bills
if (!isFollowing) {
return (
<>
<button
onClick={() => requireAuth(() => add.mutate({ type, value }))}
disabled={isPending}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Heart className="w-3.5 h-3.5" />
{label || "Follow"}
</button>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}
const { label: modeLabel, icon: ModeIcon, color } = MODES[currentMode];
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
const switchMode = (mode: FollowMode) => {
requireAuth(() => {
if (existing) updateMode.mutate({ id: existing.id, mode });
setOpen(false);
});
};
const handleUnfollow = () => {
requireAuth(() => {
if (existing) remove.mutate(existing.id);
setOpen(false);
});
};
const modeDescriptions: Record<FollowMode, string> = {
neutral: "Alert me on all material changes",
pocket_veto: "Alert me only if this bill advances toward passage",
pocket_boost: "Alert me on all changes + remind me to contact my rep",
};
return (
<>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpen((v) => !v)}
disabled={isPending}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
color
)}
>
<ModeIcon className={cn("w-3.5 h-3.5", currentMode === "neutral" && "fill-current")} />
{modeLabel}
<ChevronDown className="w-3 h-3 ml-0.5 opacity-70" />
</button>
{open && (
<div className="absolute right-0 mt-1 w-64 bg-card border border-border rounded-md shadow-lg z-50 py-1">
{otherModes.map((mode) => {
const { label: optLabel, icon: OptIcon } = MODES[mode];
return (
<button
key={mode}
onClick={() => switchMode(mode)}
title={modeDescriptions[mode]}
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-card-foreground transition-colors flex flex-col gap-0.5"
>
<span className="flex items-center gap-1.5 font-medium">
<OptIcon className="w-3.5 h-3.5" />
Switch to {optLabel}
</span>
<span className="text-xs text-muted-foreground pl-5">{modeDescriptions[mode]}</span>
</button>
);
})}
<div className="border-t border-border mt-1 pt-1">
<button
onClick={handleUnfollow}
className="w-full text-left px-3 py-2 text-sm bg-card hover:bg-accent text-destructive transition-colors"
>
Unfollow
</button>
</div>
</div>
)}
</div>
<AuthModal open={showAuthModal} onClose={() => setShowAuthModal(false)} />
</>
);
}

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

@@ -0,0 +1,16 @@
"use client";
import { Menu, Landmark } from "lucide-react";
export function MobileHeader({ onMenuClick }: { onMenuClick: () => void }) {
return (
<header className="md:hidden flex items-center justify-between px-4 py-3 border-b border-border bg-card shrink-0">
<button onClick={onMenuClick} className="p-2 rounded-md hover:bg-accent transition-colors" aria-label="Open menu">
<Menu className="w-5 h-5" />
</button>
<div className="flex items-center gap-2">
<Landmark className="w-5 h-5 text-primary" />
<span className="font-semibold text-sm">PocketVeto</span>
</div>
</header>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import {
Bookmark,
ChevronLeft,
ChevronRight,
HelpCircle,
LayoutDashboard,
FileText,
Users,
Tags,
Heart,
Bell,
Settings,
BarChart2,
Landmark,
LogOut,
X,
} from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
import { ThemeToggle } from "./ThemeToggle";
import { useAuthStore } from "@/stores/authStore";
const NAV = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false, requiresAuth: false },
{ href: "/bills", label: "Bills", icon: FileText, adminOnly: false, requiresAuth: false },
{ href: "/members", label: "Members", icon: Users, adminOnly: false, requiresAuth: false },
{ href: "/topics", label: "Topics", icon: Tags, adminOnly: false, requiresAuth: false },
{ href: "/following", label: "Following", icon: Heart, adminOnly: false, requiresAuth: true },
{ href: "/alignment", label: "Alignment", icon: BarChart2, 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 },
];
export function Sidebar({ onClose }: { onClose?: () => void }) {
const pathname = usePathname();
const router = useRouter();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout);
const [collapsed, setCollapsed] = useState(false);
// Mobile drawer always shows full sidebar
const isMobile = !!onClose;
const isCollapsed = collapsed && !isMobile;
useEffect(() => {
const saved = localStorage.getItem("sidebar-collapsed");
if (saved === "true") setCollapsed(true);
}, []);
function toggleCollapsed() {
setCollapsed((v) => {
localStorage.setItem("sidebar-collapsed", String(!v));
return !v;
});
}
function handleLogout() {
logout();
qc.clear();
router.replace("/login");
}
return (
<aside
className={cn(
"shrink-0 border-r border-border bg-card flex flex-col transition-all duration-200",
isCollapsed ? "w-14" : "w-56"
)}
>
{/* Header */}
<div
className={cn(
"h-14 border-b border-border flex items-center gap-2 px-4",
isCollapsed && "justify-center px-0"
)}
>
<Landmark className="w-5 h-5 text-primary shrink-0" />
{!isCollapsed && (
<>
<span className="font-semibold text-sm flex-1">PocketVeto</span>
{onClose && (
<button
onClick={onClose}
className="p-1 rounded-md hover:bg-accent transition-colors"
aria-label="Close menu"
>
<X className="w-4 h-4" />
</button>
)}
</>
)}
</div>
{/* Nav */}
<nav className="flex-1 p-2 space-y-0.5">
{NAV.filter(({ adminOnly, requiresAuth }) => {
if (adminOnly && !user?.is_admin) return false;
if (requiresAuth && !token) return false;
return true;
}).map(({ href, label, icon: Icon }) => {
const active = href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
onClick={onClose}
title={isCollapsed ? label : undefined}
className={cn(
"flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors",
isCollapsed && "justify-center px-0",
active
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
)}
>
<Icon className="w-4 h-4 shrink-0" />
{!isCollapsed && label}
</Link>
);
})}
</nav>
{/* Footer */}
<div className={cn("p-3 border-t border-border space-y-2", isCollapsed && "p-2")}>
{token ? (
user && (
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
{!isCollapsed && (
<span className="text-xs text-muted-foreground truncate max-w-[120px]" title={user.email}>
{user.email}
</span>
)}
<button
onClick={handleLogout}
className="p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent"
title="Sign out"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
)
) : !isCollapsed ? (
<div className="flex flex-col gap-2">
<Link
href="/register"
onClick={onClose}
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
>
Register
</Link>
<Link
href="/login"
onClick={onClose}
className="w-full px-3 py-1.5 text-sm font-medium text-center rounded-md border border-border text-foreground hover:bg-accent transition-colors"
>
Sign in
</Link>
</div>
) : null}
<div className={cn("flex items-center justify-between", isCollapsed && "justify-center")}>
{!isCollapsed && <span className="text-xs text-muted-foreground">Theme</span>}
<ThemeToggle />
</div>
{/* Collapse toggle — desktop only */}
{!isMobile && (
<button
onClick={toggleCollapsed}
title={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
className="w-full flex items-center justify-center p-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
{isCollapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
)}
</div>
</aside>
);
}

View File

@@ -0,0 +1,19 @@
"use client";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="p-1.5 rounded-md hover:bg-accent transition-colors text-muted-foreground hover:text-foreground"
aria-label="Toggle theme"
>
<Sun className="w-4 h-4 hidden dark:block" />
<Moon className="w-4 h-4 dark:hidden" />
</button>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { useEffect, useState } from "react";
import { X, BookOpen, GitCompare, ShieldCheck } from "lucide-react";
import Link from "next/link";
import { useAuthStore } from "@/stores/authStore";
const STORAGE_KEY = "pv_seen_welcome";
export function WelcomeBanner() {
const token = useAuthStore((s) => s.token);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!token && localStorage.getItem(STORAGE_KEY) !== "1") {
setVisible(true);
}
}, [token]);
const dismiss = () => {
localStorage.setItem(STORAGE_KEY, "1");
setVisible(false);
};
if (!visible) return null;
return (
<div className="relative bg-card border border-border rounded-lg p-5 pr-10">
<button
onClick={dismiss}
title="Dismiss"
className="absolute top-3 right-3 p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
>
<X className="w-4 h-4" />
</button>
<h2 className="font-semibold text-base mb-3">Welcome to PocketVeto</h2>
<ul className="space-y-2 mb-4">
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
<BookOpen className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
Follow bills, members, or topics get low-noise alerts when things actually move
</li>
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
<GitCompare className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
See <em>what changed</em> in plain English when bills are amended
</li>
<li className="flex items-start gap-2.5 text-sm text-muted-foreground">
<ShieldCheck className="w-4 h-4 mt-0.5 shrink-0 text-primary" />
Verify every AI claim with <strong>Back to Source</strong> citations from the bill text
</li>
</ul>
<div className="flex items-center gap-2">
<Link
href="/bills"
onClick={dismiss}
className="px-3 py-1.5 text-sm font-medium bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Browse Bills
</Link>
<button
onClick={dismiss}
className="px-3 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Dismiss
</button>
</div>
</div>
);
}