diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py
index 35b4a05..35cb9ff 100644
--- a/backend/app/api/dashboard.py
+++ b/backend/app/api/dashboard.py
@@ -6,7 +6,7 @@ from sqlalchemy import desc, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
-from app.core.dependencies import get_current_user
+from app.core.dependencies import get_optional_user
from app.database import get_db
from app.models import Bill, BillBrief, Follow, TrendScore
from app.models.user import User
@@ -15,11 +15,38 @@ from app.schemas.schemas import BillSchema
router = APIRouter()
+async def _get_trending(db: AsyncSession) -> list[dict]:
+ trending_result = await db.execute(
+ select(Bill)
+ .options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
+ .join(TrendScore, Bill.bill_id == TrendScore.bill_id)
+ .where(TrendScore.score_date >= date.today() - timedelta(days=1))
+ .order_by(desc(TrendScore.composite_score))
+ .limit(10)
+ )
+ trending_bills = trending_result.scalars().unique().all()
+ return [_serialize_bill(b) for b in trending_bills]
+
+
+def _serialize_bill(bill: Bill) -> dict:
+ b = BillSchema.model_validate(bill)
+ if bill.briefs:
+ b.latest_brief = bill.briefs[0]
+ if bill.trend_scores:
+ b.latest_trend = bill.trend_scores[0]
+ return b.model_dump()
+
+
@router.get("")
async def get_dashboard(
db: AsyncSession = Depends(get_db),
- current_user: User = Depends(get_current_user),
+ current_user: User | None = Depends(get_optional_user),
):
+ trending = await _get_trending(db)
+
+ if current_user is None:
+ return {"feed": [], "trending": trending, "follows": {"bills": 0, "members": 0, "topics": 0}}
+
# Load follows for the current user
follows_result = await db.execute(
select(Follow).where(Follow.user_id == current_user.id)
@@ -79,28 +106,9 @@ async def get_dashboard(
# Sort feed by latest action date
feed_bills.sort(key=lambda b: b.latest_action_date or date.min, reverse=True)
- # 4. Trending bills (top 10 by composite score today)
- trending_result = await db.execute(
- select(Bill)
- .options(selectinload(Bill.sponsor), selectinload(Bill.briefs), selectinload(Bill.trend_scores))
- .join(TrendScore, Bill.bill_id == TrendScore.bill_id)
- .where(TrendScore.score_date >= date.today() - timedelta(days=1))
- .order_by(desc(TrendScore.composite_score))
- .limit(10)
- )
- trending_bills = trending_result.scalars().unique().all()
-
- def serialize_bill(bill: Bill) -> dict:
- b = BillSchema.model_validate(bill)
- if bill.briefs:
- b.latest_brief = bill.briefs[0]
- if bill.trend_scores:
- b.latest_trend = bill.trend_scores[0]
- return b.model_dump()
-
return {
- "feed": [serialize_bill(b) for b in feed_bills[:50]],
- "trending": [serialize_bill(b) for b in trending_bills],
+ "feed": [_serialize_bill(b) for b in feed_bills[:50]],
+ "trending": trending,
"follows": {
"bills": len(followed_bill_ids),
"members": len(followed_member_ids),
diff --git a/backend/app/core/dependencies.py b/backend/app/core/dependencies.py
index 8e0d7fe..0071141 100644
--- a/backend/app/core/dependencies.py
+++ b/backend/app/core/dependencies.py
@@ -8,6 +8,7 @@ from app.database import get_db
from app.models.user import User
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login")
+oauth2_scheme_optional = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
async def get_current_user(
@@ -30,6 +31,19 @@ async def get_current_user(
return user
+async def get_optional_user(
+ token: str | None = Depends(oauth2_scheme_optional),
+ db: AsyncSession = Depends(get_db),
+) -> User | None:
+ if not token:
+ return None
+ try:
+ user_id = decode_token(token)
+ return await db.get(User, user_id)
+ except Exception:
+ return None
+
+
async def get_current_admin(
current_user: User = Depends(get_current_user),
) -> User:
diff --git a/frontend/app/members/page.tsx b/frontend/app/members/page.tsx
index 858ee1d..fc2add7 100644
--- a/frontend/app/members/page.tsx
+++ b/frontend/app/members/page.tsx
@@ -2,11 +2,37 @@
import { useState } from "react";
import Link from "next/link";
-import { Search } from "lucide-react";
-import { useMembers } from "@/lib/hooks/useMembers";
+import { Search, Heart } from "lucide-react";
+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 { cn, partyBadgeColor } from "@/lib/utils";
+function FollowedMemberRow({ bioguideId }: { bioguideId: string }) {
+ const { data: member } = useMember(bioguideId);
+ if (!member) return null;
+ return (
+
+
+
+ {member.name}
+
+
+ {member.party && (
+
+ {member.party}
+
+ )}
+ {member.state && {member.state}}
+ {member.chamber && {member.chamber}}
+
+
+
+
+ );
+}
+
export default function MembersPage() {
const [q, setQ] = useState("");
const [chamber, setChamber] = useState("");
@@ -18,6 +44,10 @@ export default function MembersPage() {
page, per_page: 50,
});
+ const token = useAuthStore((s) => s.token);
+ const { data: follows } = useFollows();
+ const followedMemberIds = follows?.filter((f) => f.follow_type === "member").map((f) => f.follow_value) ?? [];
+
return (
@@ -51,6 +81,21 @@ export default function MembersPage() {
+ {token && followedMemberIds.length > 0 && (
+
+
+
+ Following ({followedMemberIds.length})
+
+
+ {followedMemberIds.map((id) => (
+
+ ))}
+
+
+
+ )}
+
{isLoading ? (
Loading members...
) : (
diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx
index 698179a..e880902 100644
--- a/frontend/app/page.tsx
+++ b/frontend/app/page.tsx
@@ -1,14 +1,17 @@
"use client";
-import { TrendingUp, BookOpen, RefreshCw } from "lucide-react";
+import { TrendingUp, BookOpen, Flame, RefreshCw } from "lucide-react";
+import Link from "next/link";
import { useDashboard } from "@/lib/hooks/useDashboard";
import { BillCard } from "@/components/shared/BillCard";
import { adminAPI } from "@/lib/api";
import { useState } from "react";
+import { useAuthStore } from "@/stores/authStore";
export default function DashboardPage() {
const { data, isLoading, refetch } = useDashboard();
const [polling, setPolling] = useState(false);
+ const token = useAuthStore((s) => s.token);
const triggerPoll = async () => {
setPolling(true);
@@ -43,15 +46,38 @@ export default function DashboardPage() {
-
- Your Feed
- {data?.follows && (
+ {token ? : }
+ {token ? "Your Feed" : "Most Popular"}
+ {token && data?.follows && (
({data.follows.bills} bills · {data.follows.members} members · {data.follows.topics} topics)
)}
- {!data?.feed?.length ? (
+ {!token ? (
+
+
+
+ Sign in to personalise this feed with bills and members you follow.
+
+
+
+ Register
+
+
+ Sign in
+
+
+
+ {data?.trending?.length ? (
+
+ {data.trending.map((bill) => (
+
+ ))}
+
+ ) : null}
+
+ ) : !data?.feed?.length ? (
Your feed is empty.
Follow bills, members, or topics to see activity here.
diff --git a/frontend/components/shared/AuthGuard.tsx b/frontend/components/shared/AuthGuard.tsx
index 181dfd2..4698de9 100644
--- a/frontend/components/shared/AuthGuard.tsx
+++ b/frontend/components/shared/AuthGuard.tsx
@@ -6,7 +6,8 @@ import { useAuthStore } from "@/stores/authStore";
import { Sidebar } from "./Sidebar";
import { MobileHeader } from "./MobileHeader";
-const PUBLIC_PATHS = ["/login", "/register"];
+const NO_SHELL_PATHS = ["/login", "/register"];
+const AUTH_REQUIRED = ["/following", "/notifications"];
export function AuthGuard({ children }: { children: React.ReactNode }) {
const router = useRouter();
@@ -22,22 +23,24 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (!hydrated) return;
- if (!token && !PUBLIC_PATHS.includes(pathname)) {
+ const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
+ if (!token && needsAuth) {
router.replace("/login");
}
}, [hydrated, token, pathname, router]);
if (!hydrated) return null;
- // Public pages (login/register) render without the app shell
- if (PUBLIC_PATHS.includes(pathname)) {
+ // Login/register pages render without the app shell
+ if (NO_SHELL_PATHS.includes(pathname)) {
return <>{children}>;
}
- // Not logged in yet — blank while redirecting
- if (!token) return null;
+ // Auth-required pages: blank while redirecting
+ const needsAuth = AUTH_REQUIRED.some((p) => pathname.startsWith(p));
+ if (!token && needsAuth) return null;
- // Authenticated: render the full app shell
+ // Authenticated or guest browsing: render the full app shell
return (
{/* Desktop sidebar — hidden on mobile */}
diff --git a/frontend/components/shared/AuthModal.tsx b/frontend/components/shared/AuthModal.tsx
new file mode 100644
index 0000000..ea73c85
--- /dev/null
+++ b/frontend/components/shared/AuthModal.tsx
@@ -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 (
+
+
+
+
+
+ Sign in to follow bills
+
+
+ Create a free account to follow bills, set Pocket Veto or Pocket Boost modes, and receive alerts.
+
+
+
+ Create account
+
+
+ Sign in
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/components/shared/FollowButton.tsx b/frontend/components/shared/FollowButton.tsx
index 56a1ff8..1d00986 100644
--- a/frontend/components/shared/FollowButton.tsx
+++ b/frontend/components/shared/FollowButton.tsx
@@ -3,6 +3,8 @@
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 = {
@@ -37,9 +39,16 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
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
(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;
@@ -59,40 +68,48 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
// Simple toggle for non-bill follows
if (!supportsModes) {
const handleClick = () => {
- if (isFollowing && existing) {
- remove.mutate(existing.id);
- } else {
- add.mutate({ type, value });
- }
+ requireAuth(() => {
+ if (isFollowing && existing) {
+ remove.mutate(existing.id);
+ } else {
+ add.mutate({ type, value });
+ }
+ });
};
return (
-
+ <>
+
+ setShowAuthModal(false)} />
+ >
);
}
// Mode-aware follow button for bills
if (!isFollowing) {
return (
-
+ <>
+
+ setShowAuthModal(false)} />
+ >
);
}
@@ -100,13 +117,17 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
const otherModes = (Object.keys(MODES) as FollowMode[]).filter((m) => m !== currentMode);
const switchMode = (mode: FollowMode) => {
- if (existing) updateMode.mutate({ id: existing.id, mode });
- setOpen(false);
+ requireAuth(() => {
+ if (existing) updateMode.mutate({ id: existing.id, mode });
+ setOpen(false);
+ });
};
const handleUnfollow = () => {
- if (existing) remove.mutate(existing.id);
- setOpen(false);
+ requireAuth(() => {
+ if (existing) remove.mutate(existing.id);
+ setOpen(false);
+ });
};
const modeDescriptions: Record = {
@@ -116,49 +137,52 @@ export function FollowButton({ type, value, label, supportsModes = false }: Foll
};
return (
-
-
+ <>
+
+
- {open && (
-
- {otherModes.map((mode) => {
- const { label: optLabel, icon: OptIcon } = MODES[mode];
- return (
+ {open && (
+
+ {otherModes.map((mode) => {
+ const { label: optLabel, icon: OptIcon } = MODES[mode];
+ return (
+
+ );
+ })}
+
- );
- })}
-
-
+
-
- )}
-
+ )}
+
+
setShowAuthModal(false)} />
+ >
);
}
diff --git a/frontend/components/shared/Sidebar.tsx b/frontend/components/shared/Sidebar.tsx
index 5cc20d6..f59c107 100644
--- a/frontend/components/shared/Sidebar.tsx
+++ b/frontend/components/shared/Sidebar.tsx
@@ -20,13 +20,13 @@ import { ThemeToggle } from "./ThemeToggle";
import { useAuthStore } from "@/stores/authStore";
const NAV = [
- { href: "/", label: "Dashboard", icon: LayoutDashboard, adminOnly: false },
- { href: "/bills", label: "Bills", icon: FileText, adminOnly: false },
- { href: "/members", label: "Members", icon: Users, adminOnly: false },
- { href: "/topics", label: "Topics", icon: Tags, adminOnly: false },
- { href: "/following", label: "Following", icon: Heart, adminOnly: false },
- { href: "/notifications", label: "Notifications", icon: Bell, adminOnly: false },
- { href: "/settings", label: "Admin", icon: Settings, adminOnly: true },
+ { 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: "/notifications", label: "Notifications", icon: Bell, adminOnly: false, requiresAuth: true },
+ { href: "/settings", label: "Admin", icon: Settings, adminOnly: true, requiresAuth: false },
];
export function Sidebar({ onClose }: { onClose?: () => void }) {
@@ -34,6 +34,7 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
const router = useRouter();
const qc = useQueryClient();
const user = useAuthStore((s) => s.user);
+ const token = useAuthStore((s) => s.token);
const logout = useAuthStore((s) => s.logout);
function handleLogout() {
@@ -55,7 +56,11 @@ export function Sidebar({ onClose }: { onClose?: () => void }) {
- {user && (
-
-
- {user.email}
-
-
+ {token ? (
+ <>
+ {user && (
+
+
+ {user.email}
+
+
+
+ )}
+ >
+ ) : (
+
+
+ Register
+
+
+ Sign in
+
)}
diff --git a/frontend/lib/hooks/useFollows.ts b/frontend/lib/hooks/useFollows.ts
index 40d4054..5b8e8ed 100644
--- a/frontend/lib/hooks/useFollows.ts
+++ b/frontend/lib/hooks/useFollows.ts
@@ -1,11 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { followsAPI } from "../api";
+import { useAuthStore } from "@/stores/authStore";
export function useFollows() {
+ const token = useAuthStore((s) => s.token);
return useQuery({
queryKey: ["follows"],
queryFn: () => followsAPI.list(),
staleTime: 30 * 1000,
+ enabled: !!token,
});
}
@@ -14,7 +17,10 @@ export function useAddFollow() {
return useMutation({
mutationFn: ({ type, value }: { type: string; value: string }) =>
followsAPI.add(type, value),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["follows"] });
+ qc.invalidateQueries({ queryKey: ["dashboard"] });
+ },
});
}
@@ -22,7 +28,10 @@ export function useRemoveFollow() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: number) => followsAPI.remove(id),
- onSuccess: () => qc.invalidateQueries({ queryKey: ["follows"] }),
+ onSuccess: () => {
+ qc.invalidateQueries({ queryKey: ["follows"] });
+ qc.invalidateQueries({ queryKey: ["dashboard"] });
+ },
});
}