import { Link, useNavigate, useSearchParams } from 'react-router-dom' import React, { useState, Fragment, useEffect, useRef } from 'react' import { Menu, Transition, Dialog } from '@headlessui/react' import { useQuery, keepPreviousData } from '@tanstack/react-query' import api from '../lib/api' import { tracer } from '../instrumentation' import { homeLog } from '../utils/devLog' import { MagnifyingGlassIcon, CalendarIcon, DocumentTextIcon, ChartBarIcon, BuildingLibraryIcon, ArrowRightIcon, BookOpenIcon, HeartIcon, ScaleIcon, UserGroupIcon, ChatBubbleBottomCenterTextIcon, CheckCircleIcon, MapIcon, MapPinIcon, Bars3Icon, XMarkIcon, UserCircleIcon, ChevronDownIcon, CheckIcon, EnvelopeIcon, Cog6ToothIcon, ArrowRightOnRectangleIcon, ClipboardDocumentListIcon, CodeBracketIcon, BanknotesIcon, } from '@heroicons/react/24/outline' import { useAuth } from '../contexts/AuthContext' import AddressLookup from '../components/AddressLookup' import StoryLenses from '../components/StoryLenses' import { MoneyHook, CityAtAGlance, TrendingQuestions } from '../components/HomeMoneyAndSnapshot' import { fetchPolicyQuestions } from '../api/policyQuestions' import { useLocation as useLocationContext, type LocationData } from '../contexts/LocationContext' import { formatCommunityPlaceLine } from '../utils/communityLocationLabel' // Shape of the /stats payload served from the jurisdiction_state_aggregate // rollup. Counts are scoped to the selected geography. `leaders` now means // civic/government officials only; `nonprofit_leaders` is the separate rollup // of nonprofit board members / officers (see api/routes/stats_neon.py). interface LocationStats { location?: string level?: string state?: string county?: string | null city?: string | null jurisdictions?: number school_districts?: number nonprofits?: number events?: number bills?: number persons?: number leaders?: number nonprofit_leaders?: number decisions?: number total_revenue?: number total_assets?: number trending_causes?: unknown last_updated?: string | null source?: string } // Featured stories for tabbed hero banner. // // Headline figures are NOT hard-coded. Any `{metric}` token below is replaced at // render time (resolveStoryText) with the real national rollup from /api/stats — // so every number a visitor sees traces to a warehouse figure. Stories whose // hook has no real backing metric carry no number at all (we de-numbered the // former invented "$2.5B" / "8,500+ providers" style copy rather than fabricate). const FEATURED_STORIES = [ { type: 'hero', title: 'CommunityOne', subtitle: 'Track Local Decisions. Take Action.', description: 'Follow leaders, charities, and causes in your community.', stats: '{nonprofits} nonprofits • {decisions} decisions • {bills} bills tracked • 100% free', category: 'Home', link: '/' }, { type: 'story', title: '{nonprofits} Nonprofits Working for Transparency', subtitle: 'How local journalism and civic organizations track government decisions nationwide', image: 'https://images.unsplash.com/photo-1504711434969-e33886168f5c?w=1200&h=600&fit=crop', category: 'Civic Engagement', link: '/search?q=press+freedom' }, { type: 'story', title: 'AI Policy Tracking: {decisions} Government Decisions Analyzed', subtitle: 'Machine learning models identify patterns in legislative discussions and regulatory frameworks', image: 'https://images.unsplash.com/photo-1677442136019-21780ecad995?w=1200&h=600&fit=crop', category: 'Artificial Intelligence', link: '/search?q=artificial+intelligence' }, { type: 'story', title: 'Healthcare Access: Dental Clinics in Focus', subtitle: 'Tracking dental health providers and community health initiatives nationwide', image: 'https://images.unsplash.com/photo-1588776814546-1ffcf47267a5?w=1200&h=600&fit=crop', category: 'Health & Medicine', link: '/search?q=dental+health' }, { type: 'story', title: 'Local Sports Funding in Community Programs', subtitle: 'Analysis of recreation budget allocation across local jurisdictions', image: 'https://images.unsplash.com/photo-1461896836934-ffe607ba8211?w=1200&h=600&fit=crop', category: 'Sports & Recreation', link: '/search?q=sports+funding' }, { type: 'story', title: 'Social Media Policies: Cities Set New Guidelines', subtitle: 'How local governments are establishing digital communication standards', image: 'https://images.unsplash.com/photo-1611162617474-5b21e879e113?w=1200&h=600&fit=crop', category: 'Technology', link: '/search?q=social+media+policy' }, { type: 'story', title: 'Business Development and Economic Initiatives', subtitle: 'Economic development zones, tax incentives, and small business support programs', image: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?w=1200&h=600&fit=crop', category: 'Business & Markets', link: '/search?q=business+development' }, { type: 'story', title: 'Government Transparency: {events} Meetings Archived', subtitle: 'Comprehensive archive of city council, county board, and planning commission meetings', image: 'https://images.unsplash.com/photo-1555421689-d68471e189f2?w=1200&h=600&fit=crop', category: 'Government', link: '/documents' }, ] // Featured-story `{metric}` token -> LocationStats field. Resolved against the // national /api/stats rollup so headline numbers are real, never hard-coded. const STORY_METRIC_KEYS: Record = { nonprofits: 'nonprofits', decisions: 'decisions', events: 'events', bills: 'bills', persons: 'persons', leaders: 'leaders', } // Replace `{metric}` tokens in story copy with the real national figure. A token // whose metric is unavailable/zero is dropped (and surrounding whitespace tidied) // rather than shown as a fabricated number or a bare "0". function resolveStoryText(text: string, stats: LocationStats | null | undefined): string { return text .replace(/\{(\w+)\}/g, (_m, key: string) => { const statKey = STORY_METRIC_KEYS[key] const v = statKey ? (stats?.[statKey] as number | undefined) : undefined return v != null && v > 0 ? v.toLocaleString() : '' }) .replace(/\s{2,}/g, ' ') .trim() } type HeroSearchCategoryTab = | 'all' | 'meetings' | 'leaders' | 'nonprofit_leaders' | 'persons' | 'nonprofits' | 'decisions' | 'causes' | 'bills' | 'grants' | 'transcripts' | 'questions' | 'topics' | 'donors' const HERO_SEARCH_TAB_DEFS: { id: HeroSearchCategoryTab label: string types: string /* `activity` surfaces an orange "new this week" dot in the category dropdown. */ activity?: boolean /* Shown in the input when this category is active (the box narrows a browsable list). */ filterPlaceholder?: string }[] = [ { id: 'all', label: 'All', types: 'meetings,decisions,causes,leaders,organizations,bills,topics,questions,documents' }, { id: 'meetings', label: 'Meetings', types: 'meetings', activity: true, filterPlaceholder: 'Filter meetings by body or topic…' }, // Meeting transcripts (full-text). Most policy terms (e.g. "fluoride") are // discussed in the transcript body, not in meeting titles or extracted // decisions — so this is often the only category that surfaces them. { id: 'transcripts', label: 'Videos', types: 'documents', activity: true, filterPlaceholder: 'Filter meeting transcripts by topic…' }, { id: 'decisions', label: 'Decisions', types: 'decisions', activity: true, filterPlaceholder: 'Filter decisions by topic or body…' }, { id: 'leaders', label: 'Leaders', types: 'leaders', filterPlaceholder: 'Filter leaders by name or office…' }, // Nonprofit board members / officers — distinct from civic `leaders`. No // dedicated search type yet, so browsing drills into the person index. { id: 'nonprofit_leaders', label: 'Nonprofit leaders', types: 'persons', filterPlaceholder: 'Filter nonprofit board members by name…' }, { id: 'persons', label: 'Persons', types: 'persons', filterPlaceholder: 'Filter people by name…' }, { id: 'nonprofits', label: 'Nonprofits', types: 'organizations', filterPlaceholder: 'Filter nonprofits by name or cause…' }, { id: 'causes', label: 'Causes', types: 'causes', filterPlaceholder: 'Filter causes by name…' }, // Cross-jurisdiction policy questions (the "big questions" registry) and the // civic meeting-topic index — both have dedicated search types + server COUNTs. { id: 'questions', label: 'Questions', types: 'questions', filterPlaceholder: 'Filter questions by policy topic…' }, { id: 'topics', label: 'Topics', types: 'topics', filterPlaceholder: 'Filter topics by theme…' }, { id: 'bills', label: 'Bills', types: 'bills', filterPlaceholder: 'Filter bills by number or topic…' }, { id: 'grants', label: 'Grants', types: 'grants', filterPlaceholder: 'Filter grants by organization or purpose…' }, { id: 'donors', label: 'Donors', /* No dedicated donor index yet — combined people + orgs until search adds a donors type. */ types: 'persons,organizations', filterPlaceholder: 'Filter donors by name…', }, ] function formatCompactCount(n: number | undefined): string | undefined { if (n == null || Number.isNaN(n) || n < 0) return undefined if (n >= 1_000_000) { const x = n / 1_000_000 return x >= 10 ? `${Math.round(x)}M` : `${x.toFixed(1).replace(/\.0$/, '')}M` } if (n >= 1_000) { const x = n / 1_000 return x >= 100 ? `${Math.round(x)}K` : `${x.toFixed(1).replace(/\.0$/, '')}K` } return String(n) } // The hero category-counts query (below) fetches at limit=1, but the /search // backend over-fetches by +100 for cross-type mixing/sorting (search.py: // search_limit = offset + limit + 100), so each per-type `type_totals` value // saturates at 101. A saturated value is a floor ("at least this many"), not an // exact count — render it as "100+" rather than a misleading exact "101". const HERO_COUNT_QUERY_LIMIT = 1 const HERO_COUNT_CAP = HERO_COUNT_QUERY_LIMIT + 100 // 101 export default function Home() { const navigate = useNavigate() const [searchParams] = useSearchParams() const [showStrategicPlan, setShowStrategicPlan] = useState(false) const [keyword, setKeyword] = useState('') const [debouncedKeyword, setDebouncedKeyword] = useState('') const [searchScope, setSearchScope] = useState('city') // city, county, state, national, community (school) const [selectedTab, setSelectedTab] = useState(0) const [selectedStoryTab, setSelectedStoryTab] = useState(0) const [heroSearchTab, setHeroSearchTab] = useState('all') const [showSuggestions, setShowSuggestions] = useState(false) const [focused, setFocused] = useState(false) const [scopeOpen, setScopeOpen] = useState(false) const [catOpen, setCatOpen] = useState(false) const [mobileMenuOpen, setMobileMenuOpen] = useState(false) const [showLoginMenu, setShowLoginMenu] = useState(false) const { location, setLocation } = useLocationContext() const { user, isAuthenticated, login, logout, isLoading } = useAuth() const searchContainerRef = useRef(null) const scopeRef = useRef(null) const catRef = useRef(null) const DOCS_URL = import.meta.env.PROD ? 'https://www.communityone.com/docs/intro' : 'http://localhost:3000/docs/intro' // After OAuth returns to '/', honor a pending "set up my feed" intent stashed // before the redirect (from the Close-to-Home gate) and forward to the wizard. // We send the user even if profile_completed is already true, so the click // doubles as an "edit my feed" entry (the wizard pre-fills from config). useEffect(() => { if (isLoading || !isAuthenticated) return if (localStorage.getItem('feed_setup_intent') === '1') { localStorage.removeItem('feed_setup_intent') navigate('/feed-setup') } }, [isLoading, isAuthenticated, navigate]) // Debounce keyword input (300ms delay) useEffect(() => { const timer = setTimeout(() => { homeLog('⏱️ [Home] Debounced keyword update:', keyword); setDebouncedKeyword(keyword); }, 300); return () => { clearTimeout(timer); }; }, [keyword]); // Close suggestions dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) { setShowSuggestions(false); } }; if (showSuggestions) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [showSuggestions]); // Close location scope dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (scopeRef.current && !scopeRef.current.contains(event.target as Node)) { setScopeOpen(false); } }; if (scopeOpen) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [scopeOpen]); // Close category dropdown when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if (catRef.current && !catRef.current.contains(event.target as Node)) { setCatOpen(false); } }; if (catOpen) { document.addEventListener('mousedown', handleClickOutside); return () => { document.removeEventListener('mousedown', handleClickOutside); }; } }, [catOpen]); // Fetch stats based on location AND search scope const { data: locationStats } = useQuery({ queryKey: ['location-stats', location?.state, location?.city, location?.county, location?.granularity, searchScope], queryFn: async () => { if (!location) return null; const params: any = {}; // Only include parameters based on selected scope if (searchScope === 'city' && location.city) { params.state = location.state; params.county = location.county; params.city = location.city; } else if (searchScope === 'county' && location.county) { params.state = location.state; params.county = location.county; } else if (searchScope === 'state' && location.state) { params.state = location.state; } else if (searchScope === 'community' && location.city) { // School boards - city level params.state = location.state; params.city = location.city; } else { // Fallback to state level if scope doesn't match available data params.state = location.state; } homeLog('📊 [Home] Fetching stats for scope:', searchScope, 'params:', params); const response = await api.get('/stats', { params }); homeLog('📊 [Home] Location stats:', response.data); return response.data; }, enabled: !!location, staleTime: 5 * 60 * 1000 // Cache for 5 minutes }); // National catalog rollup (GET /api/stats with no location) — the real source // for the "Search in" badges when the user has no location set and isn't // searching. Replaces the former hard-coded count strings: every badge number // now traces to a warehouse figure (or shows nothing when none exists). const { data: nationalStats } = useQuery({ queryKey: ['national-stats'], queryFn: async () => { const response = await api.get('/stats'); return response.data; }, staleTime: 30 * 60 * 1000 // Catalog totals barely move; cache 30 min. }); // Live, query-scoped counts for the "Search in" badges. The preview query // above only fetches the ACTIVE tab's types, so it can't populate per-category // badges; this fans out across every category type at limit=1 (we consume only // `type_totals`, not the rows) so each badge shows how many results that // category will ACTUALLY return for the current query. Without this the badges // read the /stats catalog rollup regardless of the query — which misreports // bills (jurisdiction_state_aggregate.bills_count is unfilled -> "Bills 0") // and grants (no rollup column at all -> no badge), even though both are fully // searchable. Scoped to the active state so the count matches the search the // user will run. const { data: categoryCountsData } = useQuery({ queryKey: ['hero-category-counts', debouncedKeyword, location?.state, searchScope], queryFn: async () => { const hasKw = debouncedKeyword.length >= 2 // Browse mode (no keyword): meetings, decisions + transcripts need a live // count — they have no catalog-rollup field in /stats, so without this they // render blank. All three have real, uncapped COUNT helpers server-side // (count_events / count_decisions / count_documents, the last GIN-index- // backed + 1h-cached), so this stays cheap; the other categories use the // /stats rollup in browse. const params: any = { types: hasKw ? 'meetings,decisions,leaders,organizations,causes,questions,topics,bills,grants,documents' : 'meetings,decisions,documents', limit: HERO_COUNT_QUERY_LIMIT, } if (hasKw) params.q = debouncedKeyword if (location?.state && searchScope !== 'national') params.state = location.state const response = await api.get('/search/', { params }) return response.data }, enabled: true, staleTime: 60 * 1000, placeholderData: keepPreviousData, }) // Real directory counts for the StoryLenses "Browse" pills (Topics / Causes / // Questions). Each count matches what its pill opens: topics & causes are the // search type_totals (the same number the /search?types= browse lands on), // questions is the policy-question registry size. State-scoped to match the // pills' destinations. Any failure leaves a count undefined → the pill shows // no number (BrowseCount hides it) rather than a fabricated one. const { data: directoryCounts } = useQuery<{ topics: number | null; causes: number | null; questions: number | null }>({ queryKey: ['home-directory-counts', location?.state, searchScope], queryFn: async () => { const params: any = { types: 'topics,causes', limit: 1 } if (location?.state && searchScope !== 'national') params.state = location.state const [searchRes, questions] = await Promise.all([ api.get('/search/', { params }).then((r) => r.data).catch(() => null), fetchPolicyQuestions({ featured: true }).catch(() => [] as unknown[]), ]) const tt = (searchRes?.type_totals ?? {}) as Record return { topics: tt.topics ?? null, causes: tt.causes ?? null, questions: Array.isArray(questions) ? questions.length : null, } }, staleTime: 5 * 60 * 1000, }) // Hero category badge counts. Two sources, in priority order: // 1. Live, query-scoped `type_totals` (above) when a query is active and the // category is backed by an ENABLED live search. This is the honest "what // you'll get" number — and the only one correct for bills/grants/decisions. // 2. Geography-scoped catalog rollup from /stats (jurisdiction_state_aggregate) // for browse mode and for the persons-backed categories, whose server-side // search is disabled (a live 0 there would be misleading, so we keep the // "how many exist" catalog figure instead). // The persons-backed categories (persons, nonprofit_leaders) and 'all' have no // entry here, so they fall through to the catalog rollup / static string. const HERO_LIVE_TOTAL_KEY: Partial> = { meetings: 'meetings', decisions: 'decisions', leaders: 'leaders', nonprofits: 'organizations', causes: 'causes', questions: 'questions', topics: 'topics', bills: 'bills', grants: 'grants', transcripts: 'documents', } const HERO_COUNT_STAT_FIELD: Partial> = { leaders: 'leaders', nonprofit_leaders: 'nonprofit_leaders', persons: 'persons', nonprofits: 'nonprofits', decisions: 'decisions', bills: 'bills', } const heroCategoryCount = (cat: { id: HeroSearchCategoryTab }): string | undefined => { const hasQuery = debouncedKeyword.length >= 2 // 1. Live, query-scoped count (preferred whenever a query is active). // meetings, decisions + transcripts get a REAL, uncapped server COUNT // (count_events / count_decisions / count_documents over the small // event_meeting/event_decision/event_documents marts) and have no usable // /stats rollup field, so use their live count even in browse mode. const liveKey = HERO_LIVE_TOTAL_KEY[cat.id] const isRealCount = cat.id === 'meetings' || cat.id === 'decisions' || cat.id === 'transcripts' if ((hasQuery || isRealCount) && liveKey) { const totals = categoryCountsData?.type_totals as Record | undefined const live = totals?.[liveKey] if (live != null) { // Over-fetch estimates (len-based) saturate at the cap → "100+". The real // COUNT categories (meetings/decisions) are exact, so never cap them. if (!isRealCount && live >= HERO_COUNT_CAP) return `${HERO_COUNT_CAP - 1}+` return formatCompactCount(live) } } const statKey = HERO_COUNT_STAT_FIELD[cat.id] // 2. Geography-scoped catalog rollup. The rollup is unfilled for many // locations, so a 0 means "not rolled up yet", not "none exist" — suppress // it rather than render a misleading "0" (this is why bills no longer shows // a false "0" in browse mode). if (location && statKey) { const v = locationStats?.[statKey] as number | undefined if (v != null && v > 0) { const real = formatCompactCount(v) if (real != null) return real } } // 3. National catalog rollup — only when the user has no location set, so we // never render a national figure next to a location-scoped view. This is the // real number that replaced the former hard-coded fallback strings. if (!location && statKey) { const v = nationalStats?.[statKey] as number | undefined if (v != null && v > 0) { const real = formatCompactCount(v) if (real != null) return real } } // No real figure for this category (e.g. causes has no catalog count and no // active query) → show no badge rather than invent a number. return undefined } const activeHeroTab = React.useMemo( () => HERO_SEARCH_TAB_DEFS.find((t) => t.id === heroSearchTab) ?? HERO_SEARCH_TAB_DEFS[0], [heroSearchTab], ) const heroSearchTypes = activeHeroTab.types // Placeholder adapts to the active scope: the broad "All" prompt, or a // category-specific "Filter … by …" prompt that signals the box now narrows // a browsable list. const heroSearchPlaceholder = heroSearchTab === 'all' ? 'Search topics, people, organizations, or causes…' : activeHeroTab.filterPlaceholder ?? 'Search topics, people, organizations, or causes…' // Human-readable label for the currently selected search scope (reused by the // location selector button and the live "Searching … in …" hint line). const scopeLabel = React.useMemo(() => { // No location set → default to a nationwide search (the API is hit with no // state/city filter, which returns results from everywhere). Label it // explicitly as national rather than the ambiguous "your area". if (!location) return 'the United States' switch (searchScope) { case 'city': return `My City (${location.city || '—'})` case 'county': return `My County (${location.county || '—'})` case 'community': return `School Board (${location.city || '—'})` case 'state': return `My State (${location.state})` case 'national': return 'National' default: return 'your area' } }, [location, searchScope]) const scopeNoun = React.useMemo(() => { if (heroSearchTab === 'all') return 'everything' const row = HERO_SEARCH_TAB_DEFS.find((t) => t.id === heroSearchTab) return (row?.label ?? 'everything').toLowerCase() }, [heroSearchTab]) // The next-broader geography for the "expand search area" affordance shown // when a scoped search returns no results. Walks city → county → state → // national, skipping levels the current location can't support. `null` once // already nationwide (nothing left to broaden to). const broaderScope = React.useMemo<{ value: string; label: string } | null>(() => { if (!location) return null const order = ['city', 'community', 'county', 'state', 'national'] const currentRank = order.indexOf(searchScope) const candidates: { value: string; available: boolean; label: string }[] = [ { value: 'county', available: !!location.county, label: location.county ? `${location.county} County` : 'your county' }, { value: 'state', available: !!location.state, label: location.state || 'your state' }, { value: 'national', available: true, label: 'nationwide' }, ] for (const c of candidates) { if (order.indexOf(c.value) > currentRank && c.available) return { value: c.value, label: c.label } } return null }, [location, searchScope]) // Generate dynamic subtitle based on location and search scope const getSubtitle = () => { // If national scope, show national message regardless of location if (searchScope === 'national') { return 'Track Decisions Nationwide'; } if (!location) { return 'Track Local Decisions. Take Action.'; } if (searchScope === 'city' && location.city && location.state) { return `Track Decisions in ${location.city}, ${location.state}`; } if (searchScope === 'county' && location.county && location.state) { return `Track Decisions in ${location.county}, ${location.state}`; } if (searchScope === 'state' && location.state) { return `Track Decisions in ${location.state}`; } if (searchScope === 'community' && location.city && location.state) { return `Track School Board Decisions in ${location.city}, ${location.state}`; } // Fallback based on location only if (location.city && location.state) { return `Track Decisions in ${location.city}, ${location.state}`; } if (location.state) { return `Track Decisions in ${location.state}`; } return 'Track Local Decisions. Take Action.'; }; // Highlight matching text in dropdown results const highlightMatch = (text: string, keyword: string) => { if (!keyword || !text) return text; const parts = text.split(new RegExp(`(${keyword})`, 'gi')); return ( <> {parts.map((part, i) => part.toLowerCase() === keyword.toLowerCase() ? ( {part} ) : ( part ) )} ); }; // Filter results to only include items containing the keyword const filterResults = (results: any[], keyword: string, resultType?: string) => { if (!keyword || keyword.length < 2) return results; const lowerKeyword = keyword.toLowerCase(); return results.filter((result: any) => { // For topics, only check the title (topic field) - be strict if (resultType === 'topics') { return result.title?.toLowerCase().includes(lowerKeyword); } // For other types, check all searchable fields const searchableText = [ result.title, result.subtitle, result.description, result.name, result.jurisdiction_name ].filter(Boolean).join(' ').toLowerCase(); return searchableText.includes(lowerKeyword); }); }; // Live search preview (type-ahead with actual results from API) const { data: previewResults, isLoading: previewLoading, error: previewError } = useQuery({ queryKey: ['search-preview-home', debouncedKeyword, location?.state, searchScope, heroSearchTypes], queryFn: async () => { homeLog('🔍 [Home] Fetching preview for:', debouncedKeyword, 'in state:', location?.state); if (!debouncedKeyword || debouncedKeyword.length < 2) { homeLog('⚠️ [Home] Query too short, skipping'); return null; } try { const url = '/search/'; const params: any = { q: debouncedKeyword, types: heroSearchTypes, limit: 12 // Higher limit to show results from multiple types }; // Add state filter based on search scope (don't filter if scope is 'national') if (location && location.state && searchScope !== 'national') { params.state = location.state; homeLog('📍 [Home] Filtering by state:', location.state); } else if (searchScope === 'national') { homeLog('🌎 [Home] National search - no state filter'); } homeLog('📤 [Home] API Request:', url, params); const response = await api.get(url, { params }); homeLog('📥 [Home] API Response:', response.data); homeLog('📊 [Home] Total results:', response.data.total_results); return response.data; } catch (error: any) { console.error('❌ [Home] Search preview error:', error); console.error('❌ [Home] Error details:', { message: error.message, response: error.response, status: error.response?.status, data: error.response?.data }); throw error; } }, enabled: debouncedKeyword.length >= 2 && showSuggestions, staleTime: 1000, retry: false // Don't retry to see errors immediately }); // Log when preview state changes useEffect(() => { homeLog('🔄 [Home] Preview state:', { keyword, keywordLength: keyword.length, showSuggestions, isLoading: previewLoading, hasError: !!previewError, hasResults: !!previewResults, totalResults: previewResults?.total_results }); }, [previewResults, showSuggestions, keyword, previewLoading, previewError]); // Handle tab parameter from URL useEffect(() => { const tabParam = searchParams.get('tab') if (tabParam === 'community') { setSelectedTab(1) // Open location modal } }, [searchParams]) // When location is set, default to city search if currently on 'community' (placeholder) useEffect(() => { if (location && searchScope === 'community') { setSearchScope('city') } }, [location, searchScope]) const handleKeywordChange = (e: React.ChangeEvent) => { const value = e.target.value setKeyword(value) setShowSuggestions(value.length >= 2) } // Apply the active location scope to a /search URL the same way the // `location-stats` query (above) does, so navigation stays consistent with // the badge counts. UnifiedSearch + /api/search currently accept only // `state` and `city` (no `county` param), so we forward city+state and // intentionally omit county even when the scope is county-level. const applyLocationScope = (params: URLSearchParams) => { if (!location) return // National scope is explicitly UN-scoped: never attach a state/city filter. // Without this early return a "National" search falls into the state-level // branch below and leaks the user's home state (e.g. State: AL) onto the // results page even though they asked for nationwide. if (searchScope === 'national') return if (searchScope === 'city' && location.city) { if (location.state) params.set('state', location.state) params.set('city', location.city) } else if (searchScope === 'community' && location.city) { // School boards — city level if (location.state) params.set('state', location.state) params.set('city', location.city) } else if (location.state) { // county / state / fallback: state-level only (no county param downstream) params.set('state', location.state) } } const handleSelectSuggestion = (suggestion: string) => { setKeyword(suggestion) setShowSuggestions(false) // Navigate to search results with the selected suggestion const params = new URLSearchParams() params.set('q', suggestion) if (heroSearchTab !== 'all') { params.set('types', heroSearchTypes) } applyLocationScope(params) navigate(`/search?${params.toString()}`, { state: { fromHome: true } }) } // Click handler for a preview result row. Prefer the deep-link `url` the // search API already returns (e.g. an org's EIN link that auto-expands it on // /search) so the row navigates to the actual entity, not a generic // re-search. Fall back to searching by the row's title when no url is present. const handleResultClick = (result: any) => { const url = result?.url as string | undefined if (url) { setShowSuggestions(false) if (/^https?:\/\//i.test(url)) { window.open(url, '_blank', 'noopener,noreferrer') } else { navigate(url) } return } handleSelectSuggestion(result?.title ?? '') } const handleViewAllCategory = (category: string) => { if (keyword.trim().length >= 2) { const params = new URLSearchParams() params.set('q', keyword) params.set('types', category) applyLocationScope(params) navigate(`/search?${params.toString()}`, { state: { fromHome: true } }) } } const handleSearch = (e?: React.FormEvent) => { e?.preventDefault() homeLog('🔍 [Home] Search submitted:', { keyword, location: location?.state, searchScope }); const q = keyword.trim() // Unified search supports browse-by-type with no query; hero still requires a query on "All". if (!q && heroSearchTab === 'all') { console.warn('⚠️ [Home] Search submitted with empty keyword'); return } const params = new URLSearchParams() if (q) params.set('q', q) if (heroSearchTab !== 'all') { params.set('types', heroSearchTypes) } applyLocationScope(params) homeLog('📍 [Home] Applied location scope:', { scope: searchScope, state: params.get('state'), city: params.get('city') }) // Trace the search submission. Attributes are low-cardinality only — we // record the query *length* and presence, never the raw query string // (privacy + cardinality), mirroring the API's search spans. const searchSpan = tracer.startSpan('search.submit', { attributes: { 'search.q.length': q.length, 'search.has_query': q.length > 0, 'search.scope': searchScope, 'search.tab': heroSearchTab, }, }) searchSpan.end() const searchUrl = `/search?${params.toString()}` homeLog('🚀 [Home] Navigating to:', searchUrl) navigate(searchUrl, { state: { fromHome: true } }) } const handleAddressFound = (locationData: LocationData) => { homeLog('📍 [Home] Address found, updating location:', locationData) setLocation({ address: locationData.address, state: locationData.state, county: locationData.county, city: locationData.city, granularity: locationData.granularity, latitude: locationData.latitude, longitude: locationData.longitude, }) if (locationData.granularity === 'state') { setSearchScope('state') } else if (locationData.granularity === 'county') { setSearchScope('county') } else { setSearchScope('city') } // Close the modal and return to search tab setSelectedTab(0) } // Debug: Log when location changes useEffect(() => { homeLog('📍 [Home] Location state changed:', location); homeLog('📍 [Home] Current subtitle:', getSubtitle()); }, [location]); // Smooth scroll to section with offset for sticky header const scrollToSection = (sectionId: string) => { const element = document.getElementById(sectionId) if (element) { const offset = 80 // Account for sticky header (h-20 = 80px) const elementPosition = element.getBoundingClientRect().top const offsetPosition = elementPosition + window.pageYOffset - offset window.scrollTo({ top: offsetPosition, behavior: 'smooth' }) } } /** Top nav "Search": return to hero and focus the main search field (no route change). */ const scrollToTopHeroSearch = () => { window.scrollTo({ top: 0, behavior: 'smooth' }) window.setTimeout(() => { document.getElementById('hero-search-input')?.focus({ preventScroll: true }) }, 350) } return (
{/* Navigation Header */} {/* Money hook — "How much of your money is on the line?" Opens the interactive guess-and-reveal money game in a popup modal, wired to REAL /api/local-finance figures, plus the "Grandkids forecast" mobility panel on REAL Opportunity Atlas data (/api/grandkid-outlook). The prototype's invented household tax total is intentionally dropped. */} setSelectedTab(1)} /> {/* The "Grandkids forecast" intergenerational-mobility panel now lives inside the money-game modal (MoneyGameModal), opened from above, scoped to the same location. */} {/* Featured Story Hero */}
{/* Story Content - Conditional rendering based on type */} {FEATURED_STORIES[selectedStoryTab].type === 'hero' ? ( /* Hero Search Interface */

Your community. Your decisions.

Follow leaders, track local decisions, and find support — all in one place. Free, forever.

Search examples include school board budget, mental health nonprofit, zoning, and transit. {/* Trending questions — real policy-question chips (/api/policy-question/); hidden when none exist. */} navigate(`/policy-question/${qid}`)} /> {/* Search Box — single unified rounded pill bar */}
{/* 0. Category scope — dropdown on the left, part of the search action */}
{catOpen && (

Search in

{HERO_SEARCH_TAB_DEFS.map((cat) => { const selected = heroSearchTab === cat.id const countBadge = heroCategoryCount(cat) return ( ) })}
)}
{/* vertical divider between category and query */}
{/* 1. Query region (dominant) */}
{ setFocused(true) if (keyword.length >= 2) { setShowSuggestions(true) } }} onBlur={() => setFocused(false)} className="min-w-0 flex-1 border-0 bg-transparent px-3 py-5 text-lg leading-snug text-[#0f2b2b] placeholder:text-[#9bb8b8] focus:outline-none focus:ring-0 md:py-6 md:text-xl" style={{ fontFamily: "'DM Sans', sans-serif" }} />
{/* 2. Location selector region (secondary) */}
{location ? ( ) : ( )} {/* Custom scope dropdown panel */} {location && scopeOpen && (
{([ { value: 'city', label: `My City (${location.city || '—'})`, disabled: !location.city }, { value: 'county', label: `My County (${location.county || '—'})`, disabled: !location.county }, { value: 'community', label: `School Board (${location.city || '—'})`, disabled: !location.city }, { value: 'state', label: `My State (${location.state})`, disabled: false }, { value: 'national', label: 'National', disabled: false }, ] as { value: string; label: string; disabled: boolean }[]).map((opt) => { const active = searchScope === opt.value return ( ) })}
)}
{/* 3. CTA submit */} {/* Error Display - Below Input */} {keyword.length >= 2 && !scopeOpen && !catOpen && previewError && (

Search Error

{(previewError as any)?.response?.status === 404 ? 'API endpoint not found. Server may still be starting.' : 'Unable to fetch results. Please try again.'}

)} {/* Loading Display - Below Input */} {keyword.length >= 2 && !scopeOpen && !catOpen && previewLoading && (
Searching...
)} {/* Preview Results Dropdown */} {keyword.length >= 2 && showSuggestions && !scopeOpen && !catOpen && !previewLoading && !previewError && previewResults && (
{/* No Results Message */} {previewResults.total_results === 0 && (

No results in {scopeLabel}

{/* Geography is the most common reason a scoped search comes back empty — offer to widen it before suggesting other fixes. */} {broaderScope ? ( <>

Nothing here yet for “{keyword.trim()}”. Try a wider area.

) : (

Try searching for different keywords or check back later.

)}
)} {/* Meetings Section */} {previewResults.total_results > 0 && previewResults.results?.meetings?.length > 0 && (() => { const filteredMeetings = filterResults(previewResults.results.meetings, keyword); return filteredMeetings.length > 0 && (
Meetings
{filteredMeetings.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Decisions Section */} {previewResults.total_results > 0 && previewResults.results?.decisions?.length > 0 && (() => { const filteredDecisions = filterResults(previewResults.results.decisions, keyword); return filteredDecisions.length > 0 && (
Decisions
{filteredDecisions.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Causes Section */} {previewResults.total_results > 0 && previewResults.results?.causes?.length > 0 && (() => { const filteredCauses = filterResults(previewResults.results.causes, keyword); return filteredCauses.length > 0 && (
Causes
{filteredCauses.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Organizations Section */} {previewResults.total_results > 0 && previewResults.results?.organizations?.length > 0 && (() => { const filteredOrgs = filterResults(previewResults.results.organizations, keyword); return filteredOrgs.length > 0 && (
Organizations
{filteredOrgs.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Leaders Section (government officials — backend `leaders` search type) */} {previewResults.total_results > 0 && previewResults.results?.leaders?.length > 0 && (() => { const filteredLeaders = filterResults(previewResults.results.leaders, keyword); return filteredLeaders.length > 0 && (
Leaders
{filteredLeaders.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* People Section (MDM person index — real people incl. residents/homeowners; backend `persons` search type) */} {previewResults.total_results > 0 && previewResults.results?.persons?.length > 0 && (() => { const filteredPersons = filterResults(previewResults.results.persons, keyword); return filteredPersons.length > 0 && (
People
{filteredPersons.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Bills Section */} {previewResults.total_results > 0 && previewResults.results?.bills?.length > 0 && (() => { const filteredBills = filterResults(previewResults.results.bills, keyword); return filteredBills.length > 0 && (
Bills
{filteredBills.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Grants Section (GivingTuesday 990 grants — backend `grants` search type) */} {previewResults.total_results > 0 && previewResults.results?.grants?.length > 0 && (() => { const filteredGrants = filterResults(previewResults.results.grants, keyword); return filteredGrants.length > 0 && (
Grants
{filteredGrants.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Topics Section */} {previewResults.total_results > 0 && previewResults.results?.topics?.length > 0 && (() => { const filteredTopics = filterResults(previewResults.results.topics, keyword, 'topics'); return filteredTopics.length > 0 && (
Topics
{filteredTopics.slice(0, 3).map((result: any, idx: number) => ( ))}
); })()} {/* Footer with total results */} {previewResults.total_results > 0 && (
)}
)}
{/* State-aware footer: trending + browse when idle, scope hint when a category or query narrows the search. */} {(() => { const trimmed = keyword.trim() const intent = trimmed.length > 0 ? 'query' : heroSearchTab === 'all' ? 'idle' : 'browse' // In the "All" scope the lens boxes stay mounted whether // the input is empty (idle) or holds a query — typing // filters the cards in place instead of unmounting them. if (heroSearchTab === 'all' && (intent === 'idle' || intent === 'query')) { return ( <> {intent === 'query' && (

Filtering {scopeNoun} in{' '} {scopeLabel} {' — press Enter to search everything'}

)} { // Navigate to scoped search results. (Previously // setKeyword(q) only filled the hero box far above // the fold, so topic pills / cards felt dead.) const params = new URLSearchParams() params.set('q', q) applyLocationScope(params) navigate(`/search?${params.toString()}`, { state: { fromHome: true } }) }} onBrowseTopics={() => navigate('/search?types=topics', { state: { fromHome: true } })} onBrowsePolicyQuestions={() => navigate('/policy-questions')} onBrowseCauses={() => navigate('/search?types=causes', { state: { fromHome: true } })} browseCounts={directoryCounts} /> ) } if (intent === 'browse') { const countBadge = heroCategoryCount(activeHeroTab) // Build the same scoped browse URL a no-query submit would, // so the count acts as a drill-down into the full list. const browseParams = new URLSearchParams() if (heroSearchTab !== 'all') browseParams.set('types', heroSearchTypes) applyLocationScope(browseParams) const browseUrl = `/search?${browseParams.toString()}` return (

Browsing{' '} {countBadge ? `all ${countBadge} ` : 'all '} {activeHeroTab.label.toLowerCase()} {' '} in {scopeLabel} {' — start typing to filter'}

) } return (

Searching {scopeNoun} in{' '} {scopeLabel}

) })()}
) : ( /* Regular Story with Image */
{resolveStoryText(FEATURED_STORIES[selectedStoryTab].title,

{resolveStoryText(FEATURED_STORIES[selectedStoryTab].title, nationalStats)}

{resolveStoryText(FEATURED_STORIES[selectedStoryTab].subtitle, nationalStats)}

{/* Story Navigation Arrows */}
)}
{/* "[City] at a glance" snapshot — 4 stat cards from /api/money-flow (tracked spending) + /api/lenses (contested / analyzed / on-the-radar). Each card shows an honest empty state when its source is null/zero. */} {/* Find My Community Modal */} {selectedTab === 1 && (
setSelectedTab(0)}>
e.stopPropagation()}>

{location ? 'Change My Community' : 'Find My Community'}

{location ? `Currently set to ${formatCommunityPlaceLine(location)}. Enter a new address to change your community.` : 'Enter your address to find local organizations, city councils, county boards, school districts, and charities near you'}

{/* Success message */} {location && (

Location Set Successfully!

You're all set for {formatCommunityPlaceLine(location)}. You can now search for topics in your community.

)}
)} {/* How It Works — aligned with /explore “From information to impact” flow */}

How it works

From information to impact

Start by choosing a cause, make a plan (learn the record, decide who to work with, then show up), find help when someone needs direct support, track the decisions that matter, and build on open data.

{( [ { href: '/browse-causes', Icon: MapPinIcon, title: 'Choose a cause', blurb: 'Roads, schools, safety, family, health, or something else.', }, { href: '/browse-topics', Icon: ClipboardDocumentListIcon, title: 'Make a plan', blurb: 'Personal and community paths, allies, and outcomes.', }, { href: '/nonprofits', Icon: HeartIcon, title: 'Find help', blurb: 'Nonprofits, programs, and family supports.', }, { href: '/documents', Icon: ChartBarIcon, title: 'Track decisions', blurb: 'Meetings, budgets, maps, and verification.', }, { href: '/data-explorer', Icon: CodeBracketIcon, title: 'Build with data', blurb: 'Open datasets, APIs, and civic tooling.', }, ] as const ).map((step) => (

{step.title}

{step.blurb}

))}
{/* Impact Section */}

Our Impact

One platform connecting residents, leaders, and funders to what's really happening on the ground

{/* Mission + PBC */}

Our Mission

CommunityOne: One Map for Every Community

Every person deserves to find the help they need and have a voice in the decisions that shape their lives. But public resources are scattered, gaps go unseen, and communities are left navigating alone.

CommunityOne changes that. One platform connects residents, leaders, and funders to what's really happening on the ground — so no community has to fight just to be seen.

{/* Temporarily hidden until PBC approval — restore when ready.

Public Benefit Corporation

CommunityOne is a public benefit corporation with a fiscal-sponsored nonprofit 501(c)(3). We are solely funded by mission-aligned impact investors and philanthropic institutions.

*/} Start Exploring
{/* Strategic Plan PDF popup */} setShowStrategicPlan(false)}>
CommunityOne Strategic Plan
Open in new tab