jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
142 kB
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<string, keyof LocationStats> = {
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<HeroSearchCategoryTab>('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<HTMLDivElement>(null)
const scopeRef = useRef<HTMLDivElement>(null)
const catRef = useRef<HTMLDivElement>(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<LocationStats | null>({
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<LocationStats | null>({
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<any>({
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<string, number | undefined>
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<Record<HeroSearchCategoryTab, string>> = {
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<Record<HeroSearchCategoryTab, keyof LocationStats>> = {
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<string, number | undefined> | 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() ? (
<span key={i} className="bg-yellow-200 font-semibold">{part}</span>
) : (
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<HTMLInputElement>) => {
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 (
<div className="min-h-screen bg-slate-300">
{/* Navigation Header */}
<nav className="sticky top-0 z-50 bg-white/95 backdrop-blur-sm border-b border-gray-200 shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-20">
{/* Logo */}
<a
href="#"
onClick={(e) => {
e.preventDefault()
window.scrollTo({ top: 0, behavior: 'smooth' })
}}
className="flex items-center gap-2 md:gap-3 cursor-pointer group"
>
<img
src="/communityone_logo.svg"
alt="CommunityOne Logo"
className="h-8 md:h-12"
/>
<div className="flex flex-col">
<span className="text-base md:text-xl font-bold" style={{ color: '#354F52' }}>
Open Navigator
</span>
<span className="hidden sm:block text-xs text-gray-500 -mt-1 group-hover:text-[#354F52] transition-colors">
The open path to everything local
</span>
</div>
</a>
{/* Desktop Navigation Links - Centered with underline */}
<div className="hidden md:flex items-center gap-8">
<button
type="button"
onClick={scrollToTopHeroSearch}
className="text-sm font-medium text-gray-600 hover:text-[#354F52] transition-colors pb-1 border-b-2 border-transparent hover:border-[#354F52] cursor-pointer"
>
Search
</button>
<button
onClick={() => scrollToSection('how-it-works')}
className="text-sm font-medium text-gray-600 hover:text-[#354F52] transition-colors pb-1 border-b-2 border-transparent hover:border-[#354F52] cursor-pointer"
>
How It Works
</button>
<button
onClick={() => scrollToSection('impact')}
className="text-sm font-medium text-gray-600 hover:text-[#354F52] transition-colors pb-1 border-b-2 border-transparent hover:border-[#354F52] cursor-pointer"
>
Impact
</button>
<button
onClick={() => scrollToSection('contact')}
className="text-sm font-medium text-gray-600 hover:text-[#354F52] transition-colors pb-1 border-b-2 border-transparent hover:border-[#354F52] cursor-pointer"
>
Contact
</button>
</div>
{/* Desktop CTA Button */}
<div className="hidden md:flex items-center gap-3">
{isLoading ? (
<div className="px-3 py-2">
<div className="animate-spin h-8 w-8 border-3 border-gray-300 border-t-primary-600 rounded-full"></div>
</div>
) : isAuthenticated && user ? (
<Menu as="div" className="relative">
<Menu.Button className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-gray-100 transition-colors">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.full_name || user.email}
className="h-9 w-9 flex-shrink-0 rounded-full border-2 border-primary-500 shadow-sm object-cover"
referrerPolicy="no-referrer"
onError={(e) => {
console.error('❌ Avatar failed to load:', user.avatar_url);
e.currentTarget.style.display = 'none';
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null;
if (fallback) fallback.style.display = 'flex';
}}
onLoad={() => {
homeLog('✅ Avatar loaded successfully:', user.avatar_url?.substring(0, 50));
}}
/>
) : null}
<div
className="h-9 w-9 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-sm shadow-sm"
style={{ display: user.avatar_url ? 'none' : 'flex' }}
>
{(user.full_name || user.username || user.email).charAt(0).toUpperCase()}
</div>
<span className="hidden md:inline text-sm font-medium text-gray-700">
{user.full_name || user.username || user.email.split('@')[0]}
</span>
<ChevronDownIcon className="hidden md:block h-4 w-4 text-gray-600" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 focus:outline-none z-50">
<div className="px-4 py-3 border-b border-gray-200">
<div className="flex items-center gap-3 mb-2">
{user.avatar_url ? (
<img
src={user.avatar_url}
alt={user.full_name || user.email}
className="h-12 w-12 rounded-full border-2 border-primary-500"
referrerPolicy="no-referrer"
onError={(e) => {
console.error('❌ Avatar (dropdown) failed to load:', user.avatar_url);
e.currentTarget.style.display = 'none';
const fallback = e.currentTarget.nextElementSibling as HTMLElement | null;
if (fallback) fallback.style.display = 'flex';
}}
/>
) : null}
<div
className="h-12 w-12 rounded-full bg-gradient-to-br from-primary-500 to-primary-600 flex items-center justify-center text-white font-bold text-lg"
style={{ display: user.avatar_url ? 'none' : 'flex' }}
>
{(user.full_name || user.username || user.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">
{user.full_name || user.username || user.email.split('@')[0]}
</p>
<p className="text-xs text-gray-500 truncate">
{user.email}
</p>
</div>
</div>
{user.oauth_provider && (
<div className="flex items-center gap-1 text-xs text-gray-400">
<span>Signed in via</span>
<span className="font-medium capitalize">{user.oauth_provider}</span>
</div>
)}
</div>
<div className="py-1">
<Menu.Item>
{({ active }) => (
<button
onClick={() => navigate('/profile')}
className={`${
active ? 'bg-gray-50' : ''
} flex items-center gap-3 w-full px-4 py-2.5 text-sm text-gray-700 hover:text-gray-900`}
>
<UserCircleIcon className="h-5 w-5" />
<span>My Profile</span>
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={() => navigate('/settings')}
className={`${
active ? 'bg-gray-50' : ''
} flex items-center gap-3 w-full px-4 py-2.5 text-sm text-gray-700 hover:text-gray-900`}
>
<Cog6ToothIcon className="h-5 w-5" />
<span>Settings</span>
</button>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<button
onClick={logout}
className={`${
active ? 'bg-red-50' : ''
} flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-600 hover:text-red-700 border-t border-gray-100 mt-1`}
>
<ArrowRightOnRectangleIcon className="h-5 w-5" />
<span className="font-medium">Sign out</span>
</button>
)}
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
) : (
<div className="relative">
<button
onClick={() => setShowLoginMenu(!showLoginMenu)}
className="h-[42px] px-6 text-white rounded-lg transition-colors text-sm font-semibold flex items-center gap-2"
style={{ backgroundColor: '#354F52' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = '#2e4346'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = '#354F52'}
>
<UserCircleIcon className="h-5 w-5" />
<span>Register/Login</span>
<ChevronDownIcon className="h-4 w-4" />
</button>
{showLoginMenu && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowLoginMenu(false)}
aria-hidden="true"
/>
{/* Menu */}
<div className="absolute right-0 mt-2 w-64 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">Sign in with:</p>
</div>
<button
onClick={() => { login('google'); setShowLoginMenu(false); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors"
>
<div className="w-6 h-6 flex items-center justify-center flex-shrink-0">
<svg viewBox="0 0 24 24" className="w-5 h-5" preserveAspectRatio="xMidYMid meet">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
</div>
<span className="text-sm font-medium text-gray-700">Google</span>
</button>
<button
onClick={() => { login('facebook'); setShowLoginMenu(false); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors"
>
<div className="w-6 h-6 flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="#1877F2">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</div>
<span className="text-sm font-medium text-gray-700">Facebook</span>
</button>
<button
onClick={() => { login('github'); setShowLoginMenu(false); }}
className="flex items-center gap-3 w-full px-4 py-3 hover:bg-gray-100 transition-colors"
>
<div className="w-6 h-6 flex items-center justify-center">
<svg viewBox="0 0 24 24" className="w-5 h-5" fill="#181717">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</div>
<span className="text-sm font-medium text-gray-700">GitHub</span>
</button>
</div>
</>
)}
</div>
)}
<div className="flex items-center gap-2">
<Link
to="/data-explorer/map/us/2024/median_household_income"
className="h-[42px] px-6 rounded-lg text-white font-semibold hover:shadow-lg transition-all flex items-center"
style={{ backgroundColor: '#354F52' }}
>
Explore Now
</Link>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
title="Documentation"
aria-label="Documentation"
className="h-[42px] w-[42px] shrink-0 rounded-lg border-2 font-semibold text-lg flex items-center justify-center transition-colors border-[#354F52] text-[#354F52] hover:bg-[#354F52]/10"
>
?
</a>
</div>
</div>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2 rounded-lg text-gray-600 hover:bg-gray-100 transition-colors"
aria-label="Toggle menu"
>
{mobileMenuOpen ? (
<XMarkIcon className="h-6 w-6" />
) : (
<Bars3Icon className="h-6 w-6" />
)}
</button>
</div>
</div>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-gray-200 bg-white">
<div className="px-4 py-3 space-y-1">
<button
type="button"
className="block w-full text-left px-4 py-3 rounded-lg text-base font-medium text-gray-700 hover:bg-gray-100"
onClick={() => {
scrollToTopHeroSearch()
setMobileMenuOpen(false)
}}
>
Search
</button>
<button
className="block w-full text-left px-4 py-3 rounded-lg text-base font-medium text-gray-700 hover:bg-gray-100"
onClick={() => {
scrollToSection('how-it-works')
setMobileMenuOpen(false)
}}
>
How It Works
</button>
<button
className="block w-full text-left px-4 py-3 rounded-lg text-base font-medium text-gray-700 hover:bg-gray-100"
onClick={() => {
scrollToSection('impact')
setMobileMenuOpen(false)
}}
>
Impact
</button>
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
title="Documentation"
aria-label="Documentation"
className="flex items-center justify-center mx-4 py-3 rounded-lg text-lg font-semibold text-[#354F52] border border-[#354F52] hover:bg-gray-100"
onClick={() => setMobileMenuOpen(false)}
>
?
</a>
<button
className="block w-full text-left px-4 py-3 rounded-lg text-base font-medium text-gray-700 hover:bg-gray-100"
onClick={() => {
scrollToSection('contact')
setMobileMenuOpen(false)
}}
>
Contact
</button>
</div>
</div>
)}
</nav>
{/* 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. */}
<MoneyHook
national={searchScope === 'national'}
stateCode={location?.state || undefined}
city={location?.city || undefined}
county={location?.county || undefined}
locationLabel={location?.city || location?.county || (location?.state ? location.state : undefined)}
onSetLocation={() => setSelectedTab(1)}
/>
{/* The "Grandkids forecast" intergenerational-mobility panel now lives
inside the money-game modal (MoneyGameModal), opened from <MoneyHook>
above, scoped to the same location. */}
{/* Featured Story Hero */}
<div className="pt-2 pb-4 md:pt-3 md:pb-7 bg-gradient-to-b from-stone-50 via-white to-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="animate-[slideUp_0.6s_ease-out]">
{/* Story Content - Conditional rendering based on type */}
{FEATURED_STORIES[selectedStoryTab].type === 'hero' ? (
/* Hero Search Interface */
<div
className="relative flex flex-col items-center justify-center pt-1 pb-3 md:pt-2 md:pb-5 px-2 md:px-6"
style={{ overflow: 'visible' }}
>
<div className="text-center max-w-6xl w-full mx-auto space-y-1 md:space-y-1.5">
<h1
className="px-1 mb-0 font-semibold leading-[1.07] tracking-tight text-[clamp(2rem,5.5vw,4.125rem)]"
style={{ fontFamily: "'Fraunces', serif" }}
>
<span className="block text-[#0f2b2b]">Your community.</span>
<span className="block text-[#1a6b6b] italic mt-0.5 font-semibold">Your decisions.</span>
</h1>
<p className="mx-auto max-w-[460px] px-2 text-[15px] sm:text-[17px] font-normal leading-[1.65] text-[#6b8a8a]">
Follow leaders, track local decisions, and find support — all in one place. Free, forever.
</p>
<span id="hero-search-hint" className="sr-only">
Search examples include school board budget, mental health nonprofit, zoning, and transit.
</span>
{/* Trending questions — real policy-question chips
(/api/policy-question/); hidden when none exist. */}
<TrendingQuestions onOpen={(qid) => navigate(`/policy-question/${qid}`)} />
{/* Search Box — single unified rounded pill bar */}
<div className="w-full max-w-4xl mx-auto px-1" ref={searchContainerRef}>
<div className="relative">
<form
onSubmit={handleSearch}
id="hero-search-primary"
className="flex flex-col items-stretch overflow-visible rounded-2xl border-2 bg-white transition-all lg:flex-row"
style={{
borderColor: focused ? '#1a6b6b' : '#d4e8e8',
boxShadow: focused
? '0 0 0 4px rgba(26,107,107,0.2), 0 4px 20px rgba(26,107,107,0.10)'
: '0 4px 20px rgba(26,107,107,0.08)',
}}
>
{/* 0. Category scope — dropdown on the left, part of the search action */}
<div ref={catRef} className="relative flex items-stretch">
<button
type="button"
onClick={() => setCatOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={catOpen}
aria-label="Search category"
className="flex items-center gap-2 rounded-t-2xl px-5 py-4 font-medium text-[#0f2b2b] transition-colors hover:bg-[#f7fafb] lg:rounded-l-2xl lg:rounded-tr-none lg:py-0"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<span className="text-[15px]">{activeHeroTab.label}</span>
{HERO_SEARCH_TAB_DEFS.some((c) => c.activity && c.id !== heroSearchTab) && (
<span
className="h-1.5 w-1.5 rounded-full bg-[#e0723a]"
title="New activity in some categories"
/>
)}
<ChevronDownIcon
className={`h-4 w-4 shrink-0 text-[#6b8a8a] transition-transform ${catOpen ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
{catOpen && (
<div
role="listbox"
aria-label="Search category"
className="absolute left-0 top-full z-50 mt-2 w-64 overflow-hidden rounded-xl border border-[#d4e8e8] bg-white py-1.5 text-left shadow-[0_8px_30px_rgba(26,107,107,0.18)]"
>
<p className="px-3 pb-2 pt-1 text-[11px] font-semibold uppercase tracking-wide text-[#9bb8b8]">
Search in
</p>
{HERO_SEARCH_TAB_DEFS.map((cat) => {
const selected = heroSearchTab === cat.id
const countBadge = heroCategoryCount(cat)
return (
<button
key={cat.id}
type="button"
role="option"
aria-selected={selected}
onClick={() => {
setHeroSearchTab(cat.id)
setCatOpen(false)
}}
className="flex w-full items-center justify-between px-3 py-2 text-sm transition-colors hover:bg-[#f7fafb]"
style={{
color: selected ? '#1a6b6b' : '#334155',
fontFamily: "'DM Sans', sans-serif",
}}
>
<span className="flex items-center gap-2">
{cat.label}
{cat.activity && (
<span
className="h-1.5 w-1.5 rounded-full bg-[#e0723a]"
title="New activity this week"
/>
)}
</span>
<span className="flex items-center gap-2">
{countBadge && (
<span className="text-[11px] font-semibold tabular-nums text-[#9bb8b8]">
{countBadge}
</span>
)}
{selected && <CheckIcon className="h-4 w-4 text-[#1a6b6b]" aria-hidden />}
</span>
</button>
)
})}
</div>
)}
</div>
{/* vertical divider between category and query */}
<div className="mx-0 hidden w-px self-stretch bg-[#d4e8e8] lg:my-3 lg:block" />
{/* 1. Query region (dominant) */}
<div className="flex flex-1 items-center pl-4 lg:pl-5">
<MagnifyingGlassIcon
className="pointer-events-none h-7 w-7 shrink-0 text-[#6b8a8a]"
aria-hidden
/>
<label htmlFor="hero-search-input" className="sr-only">
Search for topics, people, organizations, or causes
</label>
<input
id="hero-search-input"
type="search"
name="q"
autoComplete="off"
placeholder={heroSearchPlaceholder}
title='Examples: "school board budget", "mental health nonprofit", "zoning", "transit".'
aria-describedby="hero-search-hint"
value={keyword}
onChange={handleKeywordChange}
onFocus={() => {
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" }}
/>
</div>
{/* 2. Location selector region (secondary) */}
<div
ref={scopeRef}
className="relative flex items-stretch border-t border-[#d4e8e8] lg:border-l lg:border-t-0"
>
{location ? (
<button
type="button"
onClick={() => setScopeOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={scopeOpen}
aria-label="Search area"
className="flex w-full items-center justify-center gap-2 px-5 py-4 text-[15px] font-medium text-[#0f2b2b] transition-colors hover:bg-[#f7fafb] lg:py-0"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<MapPinIcon className="h-5 w-5 shrink-0 text-[#1a6b6b]" aria-hidden />
<span className="truncate max-w-[12rem]">{scopeLabel}</span>
<ChevronDownIcon
className={`h-4 w-4 shrink-0 text-[#6b8a8a] transition-transform ${scopeOpen ? 'rotate-180' : ''}`}
aria-hidden
/>
</button>
) : (
<button
type="button"
onClick={() => setSelectedTab(1)}
className="flex w-full items-center justify-center gap-2 px-5 py-4 text-[15px] font-semibold text-[#1a6b6b] transition-colors hover:bg-[#f7fafb] lg:py-0"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<MapPinIcon className="h-5 w-5 shrink-0" aria-hidden />
<span>Set location</span>
</button>
)}
{/* Custom scope dropdown panel */}
{location && scopeOpen && (
<div
role="listbox"
aria-label="Search area"
className="absolute right-0 top-full z-50 mt-2 w-72 overflow-hidden rounded-xl border border-[#d4e8e8] bg-white py-1 text-left shadow-[0_8px_30px_rgba(26,107,107,0.18)]"
>
{([
{ 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 (
<button
key={opt.value}
type="button"
role="option"
aria-selected={active}
disabled={opt.disabled}
onClick={() => {
setSearchScope(opt.value)
setScopeOpen(false)
}}
className={`flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm transition-colors ${
opt.disabled
? 'cursor-not-allowed text-[#9bb8b8]'
: active
? 'bg-[#e8f4f4] font-semibold text-[#0f2b2b]'
: 'text-[#0f2b2b] hover:bg-[#f7fafb]'
}`}
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<CheckIcon
className={`h-4 w-4 shrink-0 ${active ? 'text-[#1a6b6b]' : 'text-transparent'}`}
aria-hidden
/>
<span className="truncate">{opt.label}</span>
</button>
)
})}
<div className="my-1 border-t border-[#d4e8e8]" />
<button
type="button"
onClick={() => {
setScopeOpen(false)
setSelectedTab(1)
}}
className="flex w-full items-center gap-2 px-4 py-2.5 text-left text-sm font-medium text-[#1a6b6b] transition-colors hover:bg-[#f7fafb]"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<MapIcon className="h-4 w-4 shrink-0" aria-hidden />
Change Location
</button>
</div>
)}
</div>
{/* 3. CTA submit */}
<button
type="submit"
aria-label="Search"
className="flex items-center justify-center border-t border-[#d4e8e8] bg-[#1a6b6b] px-6 py-4 text-white transition-colors hover:bg-[#2a8585] lg:rounded-r-2xl lg:border-l lg:border-t-0 lg:py-0"
>
<MagnifyingGlassIcon className="h-6 w-6" aria-hidden />
</button>
</form>
{/* Error Display - Below Input */}
{keyword.length >= 2 && !scopeOpen && !catOpen && previewError && (
<div className="absolute top-full left-0 right-0 mt-2 z-50 bg-red-50 border border-red-200 rounded-lg shadow-lg p-3">
<div className="flex items-start gap-2">
<svg className="h-5 w-5 text-red-400 flex-shrink-0 mt-0.5" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
<div>
<p className="text-sm font-medium text-red-800">Search Error</p>
<p className="text-xs text-red-600 mt-1">
{(previewError as any)?.response?.status === 404
? 'API endpoint not found. Server may still be starting.'
: 'Unable to fetch results. Please try again.'}
</p>
</div>
</div>
</div>
)}
{/* Loading Display - Below Input */}
{keyword.length >= 2 && !scopeOpen && !catOpen && previewLoading && (
<div className="absolute top-full left-0 right-0 mt-2 z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-3">
<div className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4 text-[#354F52]" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-sm text-gray-600">Searching...</span>
</div>
</div>
)}
{/* Preview Results Dropdown */}
{keyword.length >= 2 && showSuggestions && !scopeOpen && !catOpen && !previewLoading && !previewError && previewResults && (
<div className="absolute top-full left-0 right-0 mt-2 z-50 bg-white border border-gray-200 rounded-lg shadow-xl max-h-96 overflow-y-auto">
{/* No Results Message */}
{previewResults.total_results === 0 && (
<div className="px-4 py-8 text-center">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">
No results in <span className="text-[#1a6b6b]">{scopeLabel}</span>
</h3>
{/* Geography is the most common reason a scoped search comes back
empty — offer to widen it before suggesting other fixes. */}
{broaderScope ? (
<>
<p className="mt-1 text-sm text-gray-500">
Nothing here yet for “{keyword.trim()}”. Try a wider area.
</p>
<button
type="button"
onClick={() => setSearchScope(broaderScope.value)}
className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-[#1a6b6b] bg-[#1a6b6b] px-4 py-2 text-[13px] font-semibold text-white transition-colors hover:bg-[#2a8585]"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
<MapPinIcon className="h-4 w-4" aria-hidden />
Expand to {broaderScope.label}
</button>
</>
) : (
<p className="mt-1 text-sm text-gray-500">
Try searching for different keywords or check back later.
</p>
)}
</div>
)}
{/* Meetings Section */}
{previewResults.total_results > 0 && previewResults.results?.meetings?.length > 0 && (() => {
const filteredMeetings = filterResults(previewResults.results.meetings, keyword);
return filteredMeetings.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Meetings</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('meetings')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredMeetings.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<CalendarIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* Decisions Section */}
{previewResults.total_results > 0 && previewResults.results?.decisions?.length > 0 && (() => {
const filteredDecisions = filterResults(previewResults.results.decisions, keyword);
return filteredDecisions.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<ScaleIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Decisions</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('decisions')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredDecisions.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<ScaleIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* Causes Section */}
{previewResults.total_results > 0 && previewResults.results?.causes?.length > 0 && (() => {
const filteredCauses = filterResults(previewResults.results.causes, keyword);
return filteredCauses.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<HeartIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Causes</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('causes')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredCauses.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<HeartIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">
{result.breadcrumb || result.subtitle || result.description}
</div>
</div>
</button>
))}
</div>
);
})()}
{/* Organizations Section */}
{previewResults.total_results > 0 && previewResults.results?.organizations?.length > 0 && (() => {
const filteredOrgs = filterResults(previewResults.results.organizations, keyword);
return filteredOrgs.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<BuildingLibraryIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Organizations</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('organizations')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredOrgs.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<BuildingLibraryIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* 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 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<UserGroupIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Leaders</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('leaders')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredLeaders.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<UserGroupIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">
{highlightMatch(
[result.metadata?.title, result.metadata?.jurisdiction]
.filter(Boolean)
.join(' – ') || result.subtitle || result.description,
keyword,
)}
</div>
</div>
</button>
))}
</div>
);
})()}
{/* 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 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<UserCircleIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">People</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('persons')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredPersons.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<UserCircleIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* Bills Section */}
{previewResults.total_results > 0 && previewResults.results?.bills?.length > 0 && (() => {
const filteredBills = filterResults(previewResults.results.bills, keyword);
return filteredBills.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<DocumentTextIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Bills</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('bills')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredBills.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<DocumentTextIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* 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 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<BanknotesIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Grants</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('grants')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredGrants.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<BanknotesIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* Topics Section */}
{previewResults.total_results > 0 && previewResults.results?.topics?.length > 0 && (() => {
const filteredTopics = filterResults(previewResults.results.topics, keyword, 'topics');
return filteredTopics.length > 0 && (
<div className="border-b border-gray-200">
<div className="px-4 py-2 bg-gray-50 flex items-center justify-between">
<div className="flex items-center gap-2">
<ChatBubbleBottomCenterTextIcon className="h-4 w-4 text-gray-500" />
<span className="text-xs font-semibold text-gray-700 uppercase">Topics</span>
</div>
<button
type="button"
onClick={() => handleViewAllCategory('topics')}
className="text-xs text-[#354F52] hover:text-[#2e4346] font-medium"
>
View All
</button>
</div>
{filteredTopics.slice(0, 3).map((result: any, idx: number) => (
<button
key={idx}
type="button"
onClick={() => handleResultClick(result)}
className="w-full text-left px-4 py-2 hover:bg-gray-50 flex items-start gap-3 transition-colors"
>
<ChatBubbleBottomCenterTextIcon className="h-5 w-5 text-gray-600 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-medium text-gray-900 truncate">{highlightMatch(result.title, keyword)}</div>
<div className="text-sm text-gray-600 truncate">{highlightMatch(result.subtitle || result.description, keyword)}</div>
</div>
</button>
))}
</div>
);
})()}
{/* Footer with total results */}
{previewResults.total_results > 0 && (
<div className="px-4 py-3 bg-gray-50 text-center border-t border-gray-200">
<button
type="button"
onClick={() => handleSearch()}
className="text-sm text-[#354F52] hover:text-[#2e4346] font-medium"
>
See all {previewResults.total_results} results →
</button>
</div>
)}
</div>
)}
</div>
{/* 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' && (
<p
className="mt-3 text-sm text-[#6b8a8a]"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
Filtering <span className="font-semibold text-[#0f2b2b]">{scopeNoun}</span> in{' '}
<span className="font-semibold text-[#1a6b6b]">{scopeLabel}</span>
{' — press Enter to search everything'}
</p>
)}
<StoryLenses
national={searchScope === 'national'}
query={intent === 'query' ? debouncedKeyword : undefined}
locationLabel={location?.city || location?.county || undefined}
stateCode={location?.state || undefined}
city={location?.city || undefined}
onSearch={(q) => {
// 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 (
<p
className="mt-3 text-sm text-[#6b8a8a]"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
Browsing{' '}
<Link
to={browseUrl}
className="font-semibold text-[#0f2b2b] underline decoration-[#1a6b6b]/40 underline-offset-2 transition-colors hover:text-[#1a6b6b] hover:decoration-[#1a6b6b]"
>
{countBadge ? `all ${countBadge} ` : 'all '}
{activeHeroTab.label.toLowerCase()}
</Link>{' '}
in <span className="font-semibold text-[#1a6b6b]">{scopeLabel}</span>
{' — start typing to filter'}
</p>
)
}
return (
<p
className="mt-3 text-sm text-[#6b8a8a]"
style={{ fontFamily: "'DM Sans', sans-serif" }}
>
Searching <span className="font-semibold text-[#0f2b2b]">{scopeNoun}</span> in{' '}
<span className="font-semibold text-[#1a6b6b]">{scopeLabel}</span>
</p>
)
})()}
</div>
</div>
</div>
) : (
/* Regular Story with Image */
<Link to={FEATURED_STORIES[selectedStoryTab].link} className="group block">
<div className="relative overflow-hidden rounded-2xl bg-gray-900 shadow-2xl h-[500px]">
<img
src={FEATURED_STORIES[selectedStoryTab].image}
alt={resolveStoryText(FEATURED_STORIES[selectedStoryTab].title, nationalStats)}
className="absolute inset-0 w-full h-full object-cover opacity-60 group-hover:opacity-50 transition-opacity duration-300"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent" />
<div className="absolute bottom-0 left-0 right-0 p-8 md:p-12">
<h2 className="text-3xl md:text-5xl font-bold text-white mb-4 leading-tight group-hover:text-primary-300 transition-colors">
{resolveStoryText(FEATURED_STORIES[selectedStoryTab].title, nationalStats)}
</h2>
<p className="text-lg md:text-xl text-gray-200 max-w-3xl">
{resolveStoryText(FEATURED_STORIES[selectedStoryTab].subtitle, nationalStats)}
</p>
</div>
{/* Story Navigation Arrows */}
<button
onClick={(e) => {
e.preventDefault()
setSelectedStoryTab((prev) => (prev === 0 ? FEATURED_STORIES.length - 1 : prev - 1))
}}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-white/20 backdrop-blur-sm hover:bg-white/30 rounded-full flex items-center justify-center text-white transition-all"
aria-label="Previous story"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
onClick={(e) => {
e.preventDefault()
setSelectedStoryTab((prev) => (prev === FEATURED_STORIES.length - 1 ? 0 : prev + 1))
}}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 bg-white/20 backdrop-blur-sm hover:bg-white/30 rounded-full flex items-center justify-center text-white transition-all"
aria-label="Next story"
>
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</Link>
)}
</div>
</div>
</div>
{/* "[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. */}
<CityAtAGlance
national={searchScope === 'national'}
stateCode={location?.state || undefined}
city={location?.city || undefined}
locationLabel={location?.city || location?.county || (location?.state ? location.state : undefined)}
/>
{/* Find My Community Modal */}
{selectedTab === 1 && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4" onClick={() => setSelectedTab(0)}>
<div className="bg-white rounded-2xl shadow-2xl p-8 max-w-2xl w-full" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold" style={{ color: '#354F52' }}>
{location ? 'Change My Community' : 'Find My Community'}
</h2>
<button
onClick={() => setSelectedTab(0)}
className="p-2 rounded-lg hover:bg-gray-100 transition-colors"
aria-label="Close"
>
<XMarkIcon className="h-6 w-6 text-gray-500" />
</button>
</div>
<p className="text-gray-600 mb-6">
{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'}
</p>
<AddressLookup onLocationFound={handleAddressFound} />
{/* Success message */}
{location && (
<div className="mt-8 p-6 bg-green-50 border-2 border-green-200 rounded-xl">
<div className="flex items-start gap-3 mb-4">
<CheckCircleIcon className="h-6 w-6 text-green-600 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="text-lg font-bold text-green-900 mb-1">
Location Set Successfully!
</h3>
<p className="text-green-700">
You&apos;re all set for <strong>{formatCommunityPlaceLine(location)}</strong>. You can now
search for topics in your community.
</p>
</div>
</div>
<button
onClick={() => setSelectedTab(0)}
className="w-full bg-green-600 hover:bg-green-700 text-white px-6 py-4 rounded-lg font-semibold text-lg flex items-center justify-center gap-2 transition-all shadow-lg hover:shadow-xl"
>
<MagnifyingGlassIcon className="h-6 w-6" />
Start Searching
<ArrowRightIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</div>
)}
{/* How It Works — aligned with /explore “From information to impact” flow */}
<section id="how-it-works" className="py-16 px-4 bg-gradient-to-br from-gray-50 to-gray-100">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12 max-w-3xl mx-auto">
<p className="text-xs font-semibold uppercase tracking-widest text-teal-800/90 mb-2">How it works</p>
<h2 className="text-3xl md:text-4xl font-bold mb-3" style={{ color: '#354F52' }}>From information to impact</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#52796F] to-[#84A98C] mx-auto rounded mb-4"></div>
<p className="text-lg text-gray-600 leading-relaxed">
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.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4 lg:gap-3">
{(
[
{
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) => (
<Link
key={step.title}
to={step.href}
className="flex flex-col rounded-xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-all hover:border-teal-700/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-teal-700"
>
<span className="mb-3 flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[#354F52] text-white">
<step.Icon className="h-5 w-5" aria-hidden />
</span>
<h3 className="text-base font-bold text-gray-900">{step.title}</h3>
<p className="mt-2 text-sm leading-snug text-gray-600">{step.blurb}</p>
</Link>
))}
</div>
</div>
</section>
{/* Impact Section */}
<section id="impact" className="py-16 px-4 bg-white">
<div className="max-w-7xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-2" style={{ color: '#354F52' }}>
Our Impact
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#52796F] to-[#84A98C] mx-auto rounded mb-4"></div>
<p className="mb-6 text-lg text-gray-600">
One platform connecting residents, leaders, and funders to what's really happening on the ground
</p>
<button
onClick={() => setShowStrategicPlan(true)}
className="inline-flex items-center gap-2 px-6 py-3 rounded-full font-semibold text-white shadow-md hover:shadow-xl transition-shadow"
style={{ backgroundColor: '#354F52' }}
>
<DocumentTextIcon className="h-5 w-5" />
View Our Strategic Plan
</button>
</div>
{/* Mission + PBC */}
<div id="mission" className="max-w-3xl mx-auto text-center bg-gradient-to-br from-gray-50 to-white border-2 border-gray-200 rounded-2xl p-8">
<h2 className="text-3xl md:text-4xl font-bold mb-2" style={{ color: '#354F52' }}>
Our Mission
</h2>
<div className="w-24 h-1 bg-gradient-to-r from-[#52796F] to-[#84A98C] mx-auto rounded mb-4"></div>
<p className="text-lg text-gray-600 mb-4">
CommunityOne: One Map for Every Community
</p>
<p className="text-lg text-gray-700 leading-relaxed mb-3">
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.
</p>
<p className="text-lg text-gray-700 leading-relaxed mb-6">
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.
</p>
{/* Temporarily hidden until PBC approval — restore when ready.
<div className="border-t border-gray-200 pt-6 mb-6">
<p className="text-xs font-bold uppercase tracking-widest text-[#52796F] mb-2">Public Benefit Corporation</p>
<p className="text-base text-gray-600 leading-relaxed">
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.
</p>
</div>
*/}
<Link
to="/explore"
className="inline-flex items-center gap-2 px-6 py-3 bg-[#354F52] text-white rounded-lg hover:bg-[#2e4346] transition-colors font-semibold"
>
Start Exploring <ArrowRightIcon className="h-5 w-5" />
</Link>
</div>
</div>
</section>
{/* Strategic Plan PDF popup */}
<Transition appear show={showStrategicPlan} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={() => setShowStrategicPlan(false)}>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-50" />
</Transition.Child>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Dialog.Panel className="w-full max-w-5xl h-[85vh] transform overflow-hidden rounded-2xl bg-white text-left align-middle shadow-xl transition-all flex flex-col">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
<div className="flex items-center gap-2">
<DocumentTextIcon className="h-6 w-6" style={{ color: '#52796F' }} />
<Dialog.Title as="h3" className="text-lg font-semibold text-gray-900">
CommunityOne Strategic Plan
</Dialog.Title>
</div>
<div className="flex items-center gap-4">
<a
href="/pdf/c1_strategic_plan.PDF"
target="_blank"
rel="noopener noreferrer"
className="text-sm font-medium text-[#52796F] hover:underline"
>
Open in new tab
</a>
<button
onClick={() => setShowStrategicPlan(false)}
className="text-gray-400 hover:text-gray-600 transition-colors"
aria-label="Close"
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
<iframe
src="/pdf/c1_strategic_plan.PDF"
title="CommunityOne Strategic Plan"
className="flex-1 w-full"
/>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
{/* Features Grid */}
<div className="py-16" style={{ backgroundColor: '#354F52' }}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-12 text-white">Explore the Platform</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<Link to="/data-explorer/map/us/2024/median_household_income" className="group">
<div className="bg-white border-2 border-gray-200 rounded-lg p-6 hover:border-primary-500 transition-colors">
<ChartBarIcon className="h-10 w-10 text-primary-600 mb-4" />
<h3 className="text-xl font-semibold mb-2" style={{ color: '#354F52' }}>Data & Trends</h3>
<p className="text-gray-600 mb-4">
Statistics, charts, and insights. See what's happening across communities.
</p>
<span className="text-primary-600 font-medium inline-flex items-center">
View Data <ArrowRightIcon className="h-4 w-4 ml-2" />
</span>
</div>
</Link>
<Link to="/documents" className="group">
<div className="bg-white border-2 border-gray-200 rounded-lg p-6 hover:border-primary-500 transition-colors">
<DocumentTextIcon className="h-10 w-10 text-primary-600 mb-4" />
<h3 className="text-xl font-semibold mb-2" style={{ color: '#354F52' }}>Meeting Minutes</h3>
<p className="text-gray-600 mb-4">
See what local governments are discussing, deciding, and spending
</p>
<span className="text-primary-600 font-medium inline-flex items-center">
Browse Minutes <ArrowRightIcon className="h-4 w-4 ml-2" />
</span>
</div>
</Link>
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="group">
<div className="bg-white border-2 border-gray-200 rounded-lg p-6 hover:border-primary-500 transition-colors">
<BookOpenIcon className="h-10 w-10 text-primary-600 mb-4" />
<h3 className="text-xl font-semibold mb-2" style={{ color: '#354F52' }}>Learn More</h3>
<p className="text-gray-600 mb-4">
Discover how to track local decisions and find charities
</p>
<span className="text-primary-600 font-medium inline-flex items-center">
Getting Started <ArrowRightIcon className="h-4 w-4 ml-2" />
</span>
</div>
</a>
</div>
</div>
</div>
{/* Contact Us CTA */}
<section id="contact" className="py-20 px-4 bg-gradient-to-br from-gray-50 to-blue-50">
<div className="max-w-4xl mx-auto">
<div className="bg-white rounded-2xl shadow-xl p-12 text-center">
<EnvelopeIcon className="h-16 w-16 mx-auto mb-6" style={{ color: '#354F52' }} />
<h2 className="text-4xl md:text-5xl font-bold mb-4" style={{ color: '#354F52' }}>
Get in Touch
</h2>
<p className="text-xl text-gray-600 mb-8">
Questions, feedback, or ideas? We'd love to hear from you.
Report bugs, request features, or ask questions about jurisdiction coverage.
</p>
<div className="flex justify-center">
<a
href="mailto:hello@communityone.com"
className="inline-flex items-center justify-center px-8 py-4 rounded-lg text-white font-semibold transition-all hover:shadow-lg"
style={{ backgroundColor: '#354F52' }}
>
<EnvelopeIcon className="h-5 w-5 mr-2" />
Email Us
</a>
</div>
<p className="text-sm text-gray-500 mt-6">
Your feedback helps us improve the platform for everyone.
</p>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-12 px-4 bg-gray-900 text-white">
<div className="max-w-7xl mx-auto text-center">
<div className="flex items-center justify-center gap-3 mb-6">
<img
src="/communityone_logo.svg"
alt="CommunityOne Logo"
className="h-10 opacity-90"
/>
<span className="text-xl font-bold">Open Navigator</span>
</div>
<p className="text-gray-400 mb-6">
Making community impact accessible to everyone
</p>
{/* Social Media Links */}
<div className="flex justify-center gap-6 mb-8">
<a href="https://www.instagram.com/getcommunityone/" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="Instagram">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073zm0 5.838c-3.403 0-6.162 2.759-6.162 6.162s2.759 6.163 6.162 6.163 6.162-2.759 6.162-6.163c0-3.403-2.759-6.162-6.162-6.162zm0 10.162c-2.209 0-4-1.79-4-4 0-2.209 1.791-4 4-4s4 1.791 4 4c0 2.21-1.791 4-4 4zm6.406-11.845c-.796 0-1.441.645-1.441 1.44s.645 1.44 1.441 1.44c.795 0 1.439-.645 1.439-1.44s-.644-1.44-1.439-1.44z"/>
</svg>
</a>
<a href="https://www.facebook.com/getcommunityone" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="Facebook">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</a>
<a href="https://x.com/getcommunityone/" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="X (Twitter)">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</a>
<a href="https://www.linkedin.com/company/getcommunityone" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="LinkedIn">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</a>
<a href="https://www.youtube.com/@getcommunityone" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="YouTube">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/>
</svg>
</a>
<a href="https://discord.gg/uH6Dytek" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="Discord">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</a>
<a href="https://communityone-hq.slack.com/" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="Slack">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</a>
<a href="https://github.com/getcommunityone" target="_blank" rel="noopener noreferrer" className="text-gray-400 hover:text-white transition-colors" aria-label="GitHub">
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
</div>
<nav aria-label="Site sections" className="flex flex-wrap justify-center gap-x-6 gap-y-2 text-sm text-gray-400 max-w-4xl mx-auto px-2">
<Link to="/explore" className="hover:text-white transition-colors">
Take action
</Link>
<Link to="/search" className="hover:text-white transition-colors">
Search
</Link>
<Link to="/jurisdictions" className="hover:text-white transition-colors">
Jurisdictions
</Link>
<Link to="/nonprofits" className="hover:text-white transition-colors">
Nonprofits
</Link>
<Link to="/analytics" className="hover:text-white transition-colors">
Analytics
</Link>
<Link to="/policy-map" className="hover:text-white transition-colors">
Policy map
</Link>
<Link to="/documents" className="hover:text-white transition-colors">
Documents
</Link>
<Link to="/dashboard" className="hover:text-white transition-colors">
Dashboard
</Link>
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="hover:text-white transition-colors">
Documentation
</a>
<a
href={import.meta.env.PROD ? 'https://www.communityone.com/api/docs' : 'http://localhost:8001/docs'}
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors"
>
API
</a>
</nav>
<p className="text-gray-500 text-sm mt-6">
© 2026 CommunityOne. All rights reserved.
</p>
</div>
</footer>
</div>
)
}