// Home-page sections introduced by the new design prototype, all wired to REAL // APIs with honest empty states (CLAUDE.md: No Fabricated Data). Three exports: // // "How much of your money is on the line?" — an address // card that opens the interactive guess-and-reveal money // game in a popup modal (), wired to REAL // government-finance figures (/api/local-finance). With no // location set the "Show me my money" button instead opens // the Find-My-Community address modal so the user picks a // place first (never an empty game). // "[City] at a glance" — 4 stat cards from /api/money-flow // (tracked spending) and /api/lenses (contested / analyzed // / next-to-watch). Each card shows an empty state when its // real source is null/zero. // Real policy-question chips (/api/policy-question/). // // Palette/fonts follow the repo's existing teal/DM Sans conventions used by the // hero + StoryLenses (#1a6b6b / #0f2b2b / #6b8a8a), not the prototype's raw hex. import { useState } from 'react' import { useQuery } from '@tanstack/react-query' import { BanknotesIcon, ScaleIcon, ChartBarIcon, CalendarIcon, MapPinIcon, } from '@heroicons/react/24/outline' import api from '../lib/api' import { fetchPolicyQuestions } from '../api/policyQuestions' import MoneyGameModal from './MoneyGameModal' const FONT = { fontFamily: "'DM Sans', sans-serif" } as const const SERIF = { fontFamily: "'Fraunces', serif" } as const // --------------------------------------------------------------------------- // Shared scope props — the home page only knows state (2-letter) + city + county // (no jurisdiction_id), so money-and-talk is scoped by state_code and money-flow // by state+city, exactly mirroring FollowTheMoney / MoneyTalk. // --------------------------------------------------------------------------- export interface HomeScopeProps { /** 2-letter state code, or undefined for the national view. */ stateCode?: string /** City name (money-flow + local-finance scope). */ city?: string /** County name (local-finance scope). */ county?: string /** Short, human place label for headings, e.g. "Northport" or "Alabama". */ locationLabel?: string /** When true, show the national view (no state/city filter). */ national?: boolean } // ---- /api/money-flow response (subset we read) ---- interface FlowLensLite { head_amount: string head_label: string count_label: string placeholder: boolean } interface MoneyFlowLite { location_label?: string lenses: { spending: FlowLensLite; grants: FlowLensLite; economy: FlowLensLite } } function useMoneyFlow({ stateCode, city, national }: HomeScopeProps) { const scopedState = national ? undefined : stateCode const scopedCity = national ? undefined : city return useQuery({ queryKey: ['home-money-flow', national, scopedState, scopedCity], queryFn: () => api .get('/money-flow', { params: { state: scopedState, city: scopedCity } }) .then((r) => r.data), staleTime: 5 * 60 * 1000, }) } // ---- /api/lenses response (subset we read for the snapshot) ---- interface LensActivity { icon: string value: string label: string query?: string } interface LensCard { headline: string jurisdiction: string date?: string url?: string } interface LensBlock { id: string label: string placeholder: boolean cards: LensCard[] } interface LensesLite { lenses: LensBlock[] activity: LensActivity[] location_label?: string } function useLenses({ stateCode, city, national }: HomeScopeProps) { return useQuery({ queryKey: ['home-lenses-snapshot', national, stateCode, city], queryFn: () => { const params: Record = { window: 'auto' } if (!national && stateCode) params.state = stateCode if (!national && city) params.city = city return api.get('/lenses', { params }).then((r) => r.data) }, staleTime: 5 * 60 * 1000, }) } // =========================================================================== // "[City] at a glance" — 4 snapshot stat cards, each honest about missing data. // =========================================================================== interface SnapshotCard { key: string Icon: typeof BanknotesIcon label: string value: string | null sub?: string } function StatCard({ card }: { card: SnapshotCard }) { const available = card.value != null return (
{available ? ( {card.value} ) : ( )} {card.label} {available ? card.sub : 'No data available'}
) } export function CityAtAGlance(props: HomeScopeProps) { const { locationLabel, national } = props const { data: flow } = useMoneyFlow(props) const { data: lenses } = useLenses(props) // Tracked spending — money-flow spending lens head amount (already formatted). const spending = flow?.lenses?.spending const spendValue = spending && !spending.placeholder && spending.head_amount ? spending.head_amount : null // Contested + analyzed — match by activity-tile label. const activity = lenses?.activity ?? [] const findActivity = (test: (l: string) => boolean): LensActivity | undefined => activity.find((a) => test(a.label.toLowerCase())) const contested = findActivity((l) => l.includes('contest')) const analyzed = findActivity((l) => l.includes('decision') || l.includes('analyz')) // Next vote to watch — first card of the "Watch Next" lens (the closest real // proxy; there is no upcoming-events endpoint, so we never fabricate a date). const nextLens = lenses?.lenses?.find((l) => l.id === 'next') const nextCard = nextLens && !nextLens.placeholder ? nextLens.cards?.[0] : undefined const cards: SnapshotCard[] = [ { key: 'spending', Icon: BanknotesIcon, label: 'Tracked spending', value: spendValue, sub: spending?.count_label || 'in money-flagged decisions (manual review recommended)', }, { key: 'contested', Icon: ScaleIcon, label: 'Contested decisions', value: contested ? contested.value : null, sub: 'split votes & debate', }, { key: 'analyzed', Icon: ChartBarIcon, label: 'Decisions analyzed', value: analyzed ? analyzed.value : null, sub: 'in this window', }, { key: 'next', Icon: CalendarIcon, label: 'On the radar', value: nextCard ? nextCard.jurisdiction || 'Coming back' : null, sub: nextCard ? nextCard.headline : 'No upcoming votes flagged', }, ] const place = national ? 'The U.S.' : locationLabel || 'Your community' return (

{place} at a glance

{cards.map((c) => ( ))}
) } // =========================================================================== // Trending questions — real policy-question chips. // =========================================================================== export function TrendingQuestions({ onOpen }: { onOpen: (questionId: string) => void }) { const { data } = useQuery({ queryKey: ['home-pinned-questions'], queryFn: () => fetchPolicyQuestions({ featured: true }), staleTime: 30 * 60 * 1000, }) const chips = (data ?? []).filter((q) => !!q.canonical_text) if (chips.length === 0) return null // never show fabricated questions return (
Pinned questions: {chips.map((q) => ( ))}
) } // =========================================================================== // "How much of your money is on the line?" — address card that launches the // interactive money game in a popup modal (), scoped to the // user's location and wired to REAL /api/local-finance figures. // =========================================================================== export function MoneyHook({ stateCode, city, county, national, locationLabel, onSetLocation, }: HomeScopeProps & { onSetLocation: () => void }) { // Local-only address text; passing it onward is optional — the real // "Find My Community" AddressLookup modal (opened via onSetLocation) does the // geocoding. We never fabricate a result from this string. const [address, setAddress] = useState('') const [modalOpen, setModalOpen] = useState(false) // The game needs a real state to scope /api/local-finance. The national view // has no single state, so there it always falls back to the address modal. const canPlay = !national && !!stateCode const handleSubmit = () => { // No location/state yet → let the user pick a place first (don't open an // empty game). Otherwise open the real money game modal. if (canPlay) { setModalOpen(true) } else { onSetLocation() } } return (
{/* Centered headline + subtitle */}

How much of your money is on the line?

Enter your address and discover how local decisions affect your wallet — and your grandkids' future.

{/* Centered white address card (reuses the real location flow) */}
setAddress(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault() handleSubmit() } }} placeholder="Enter your address or ZIP code" className="w-full rounded-xl border border-[#d4e8e8] bg-white py-3 pl-10 pr-3 text-[15px] text-[#0f2b2b] placeholder-[#9bb8b8] outline-none transition-colors focus:border-[#1a6b6b] focus:ring-2 focus:ring-[#1a6b6b]/20" style={FONT} />
Takes 15 seconds · No data stored {canPlay ? 'See your real local tax split.' : 'Set your community to see your real local tax split.'}
{/* The interactive money game — real /api/local-finance figures, scoped to the user's location. Only mounted/opened once a real state is known. */} {canPlay && stateCode && ( setModalOpen(false)} stateCode={stateCode} city={city} county={county} requestedLabel={locationLabel} /> )}
) }