open-navigator / web_app /src /components /HomeMoneyAndSnapshot.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
14.9 kB
// 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:
//
// <MoneyHook> "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 (<MoneyGameModal>), 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).
// <CityAtAGlance> "[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.
// <TrendingQuestions> 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<MoneyFlowLite>({
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<LensesLite>({
queryKey: ['home-lenses-snapshot', national, stateCode, city],
queryFn: () => {
const params: Record<string, string> = { 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 (
<div
className={`flex flex-col rounded-2xl border p-5 transition-all ${
available
? 'border-[#d4e8e8] bg-white shadow-[0_4px_20px_rgba(26,107,107,0.06)]'
: 'border-dashed border-[#d4e8e8] bg-[#f7fafb]'
}`}
>
<span
className={`mb-3 flex h-9 w-9 items-center justify-center rounded-full ${
available ? 'bg-[#e8f4f4] text-[#1a6b6b]' : 'bg-white text-[#9bb8b8]'
}`}
>
<card.Icon className="h-5 w-5" aria-hidden />
</span>
{available ? (
<span
className="text-[26px] font-semibold leading-none text-[#0f2b2b] tabular-nums"
style={SERIF}
>
{card.value}
</span>
) : (
<span className="text-base font-medium text-[#9bb8b8]" style={FONT}>
</span>
)}
<span className="mt-2 text-[13px] font-medium text-[#56635e]" style={FONT}>
{card.label}
</span>
<span className="mt-0.5 text-[12px] text-[#9bb8b8]" style={FONT}>
{available ? card.sub : 'No data available'}
</span>
</div>
)
}
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 (
<section className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 md:py-10">
<div className="mb-5 flex items-center gap-2">
<MapPinIcon className="h-5 w-5 text-[#1a6b6b]" aria-hidden />
<h2
className="text-2xl md:text-[28px] font-semibold text-[#0f2b2b]"
style={SERIF}
>
{place} at a glance
</h2>
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4 md:gap-4">
{cards.map((c) => (
<StatCard key={c.key} card={c} />
))}
</div>
</section>
)
}
// ===========================================================================
// 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 (
<div className="mt-4 flex flex-nowrap items-center gap-2 overflow-x-auto px-2 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<span className="shrink-0 whitespace-nowrap text-[13px] font-medium text-[#9bb8b8]" style={FONT}>
Pinned questions:
</span>
{chips.map((q) => (
<button
key={q.question_id}
type="button"
onClick={() => onOpen(q.question_id)}
className="shrink-0 whitespace-nowrap rounded-full border border-[#d4e8e8] bg-white px-3.5 py-1.5 text-[13px] font-medium text-[#1a6b6b] transition-colors hover:border-[#1a6b6b] hover:bg-[#f0f8f8]"
style={FONT}
>
{q.canonical_text}
</button>
))}
</div>
)
}
// ===========================================================================
// "How much of your money is on the line?" — address card that launches the
// interactive money game in a popup modal (<MoneyGameModal>), 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 (
<section className="bg-gradient-to-b from-stone-50 via-white to-white">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-10 md:py-14">
{/* Centered headline + subtitle */}
<div className="mx-auto max-w-2xl text-center">
<h2
className="text-3xl md:text-[42px] font-semibold leading-[1.1] tracking-tight text-[#0f2b2b]"
style={SERIF}
>
How much of <span className="text-[#1a6b6b]">your money</span> is on the line?
</h2>
<p className="mx-auto mt-3 max-w-xl text-[15px] md:text-[17px] leading-relaxed text-[#6b8a8a]" style={FONT}>
Enter your address and discover how local decisions affect your wallet — and your
grandkids&apos; future.
</p>
</div>
{/* Centered white address card (reuses the real location flow) */}
<div className="mx-auto mt-7 w-full max-w-[640px] rounded-2xl border border-[#e2eaea] bg-white p-5 md:p-6 shadow-[0_8px_30px_rgba(26,107,107,0.08)]">
<label
htmlFor="moneyhook-address"
className="mb-2 block text-[11px] font-semibold uppercase tracking-[0.14em] text-[#9bb8b8]"
style={{ fontFamily: "'DM Mono', ui-monospace, monospace" }}
>
Address or ZIP
</label>
<div className="flex flex-col gap-2.5 sm:flex-row">
<div className="relative flex-1">
<MapPinIcon className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-[#9bb8b8]" aria-hidden />
<input
id="moneyhook-address"
type="text"
value={address}
onChange={(e) => 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}
/>
</div>
<button
type="button"
onClick={handleSubmit}
className="shrink-0 rounded-xl bg-[#1a6b6b] px-5 py-3 text-[15px] font-semibold text-white transition-colors hover:bg-[#155757]"
style={FONT}
>
Show me my money
</button>
</div>
<div className="mt-3 flex flex-col gap-1 text-[12px] sm:flex-row sm:items-center sm:justify-between">
<span
className="font-medium uppercase tracking-[0.1em] text-[#9bb8b8]"
style={{ fontFamily: "'DM Mono', ui-monospace, monospace" }}
>
Takes 15 seconds · No data stored
</span>
<span className="text-[#9bb8b8]" style={FONT}>
{canPlay
? 'See your real local tax split.'
: 'Set your community to see your real local tax split.'}
</span>
</div>
</div>
</div>
{/* 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 && (
<MoneyGameModal
open={modalOpen}
onClose={() => setModalOpen(false)}
stateCode={stateCode}
city={city}
county={county}
requestedLabel={locationLabel}
/>
)}
</section>
)
}