Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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'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> | |
| ) | |
| } | |