(null)
const geometryReadyRef = useRef(false)
@@ -445,7 +448,14 @@ export const BahiaMap = ({
heightClassName,
)}
/>
- {status === 'loading' ? Carregando mapa…
: null}
+ {status === 'loading' ? (
+
+
+
+ Carregando mapa…
+
+
+ ) : null}
{status === 'error' ? (
Não foi possível carregar o mapa. Tente recarregar a página.
diff --git a/src/components/campaign/CampaignDashboard.tsx b/src/components/campaign/CampaignDashboard.tsx
index f6cfff94..fa2cea80 100644
--- a/src/components/campaign/CampaignDashboard.tsx
+++ b/src/components/campaign/CampaignDashboard.tsx
@@ -1,20 +1,18 @@
import { ArrowRightIcon } from 'lucide-react'
import Link from 'next/link'
+import type { ReactNode } from 'react'
+
import { CampaignMetricStrip } from '@/components/campaign/CampaignMetricStrip'
import {
CampaignPageShell,
campaignPrioritySurfaceClassName,
} from '@/components/campaign/CampaignPageShell'
-import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
-import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
import { RecentlyVisitedCard } from '@/components/campaign/RecentlyVisitedCard'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { formatElectionNumber } from '@/lib/electionInsights'
import type { StaffDashboardView } from '@/utilities/campaignDashboardData'
-import type { FederalCandidateOption } from '@/utilities/electionCandidateOptions'
-import type { MunicipalityMapBundle } from '@/utilities/municipalityMapContract'
import {
formatVoteEstimateEndpointsLabel,
voteEstimateScenarioLabels,
@@ -28,13 +26,12 @@ const dateTimeFormatter = new Intl.DateTimeFormat('pt-BR', {
export const CampaignDashboard = ({
view,
userName,
- mapBundle = null,
- candidateOptions = [],
+ mapSlot = null,
}: {
view: StaffDashboardView
userName: string
- mapBundle?: MunicipalityMapBundle | null
- candidateOptions?: FederalCandidateOption[]
+ /** Server-streamed map section (composition keeps this component map-agnostic). */
+ mapSlot?: ReactNode
}) => (
@@ -46,11 +43,7 @@ export const CampaignDashboard = ({
- {mapBundle ? (
-
-
-
- ) : null}
+ {mapSlot}
{
+ const router = useRouter()
+ const shared = useCampaignListPending()
+ const [isLocalPending, startLocalTransition] = useTransition()
+ const isPending = shared?.isPending ?? isLocalPending
+ const startTransition = shared?.startTransition ?? startLocalTransition
+ const [optimisticHref, setOptimisticHref] = useState(null)
+
+ const activeHref =
+ isPending && optimisticHref !== null
+ ? optimisticHref
+ : (chips.find((chip) => chip.active)?.href ?? null)
+
+ const interceptNavigation = (event: MouseEvent, href: string) => {
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return
+ }
+ event.preventDefault()
+ setOptimisticHref(href)
+ startTransition(() => {
+ router.push(href)
+ })
+ }
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/CampaignListPagination.tsx b/src/components/campaign/CampaignListPagination.tsx
index 9270e33d..cb3a4f70 100644
--- a/src/components/campaign/CampaignListPagination.tsx
+++ b/src/components/campaign/CampaignListPagination.tsx
@@ -1,3 +1,9 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useTransition, type MouseEvent } from 'react'
+
+import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import {
Pagination,
PaginationContent,
@@ -24,45 +30,83 @@ export const CampaignListPagination = ({
totalPages: number
hrefForPage: (page: number) => string
}) => {
+ const router = useRouter()
+ const shared = useCampaignListPending()
+ const [isLocalPending, startLocalTransition] = useTransition()
+ const isPending = shared?.isPending ?? isLocalPending
+ const startTransition = shared?.startTransition ?? startLocalTransition
+
if (totalPages <= 1) return null
const visiblePages = getPaginationPages(page, totalPages)
- return (
-
-
-
-
-
- {visiblePages.map((pageNumber, index) => {
- const previousPage = visiblePages[index - 1]
- const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
+ /** Plain left-clicks navigate in a transition (pending UI); modified clicks keep link semantics. */
+ const interceptNavigation = (event: MouseEvent, href: string) => {
+ if (
+ event.defaultPrevented ||
+ event.button !== 0 ||
+ event.metaKey ||
+ event.ctrlKey ||
+ event.shiftKey ||
+ event.altKey
+ ) {
+ return
+ }
+ event.preventDefault()
+ startTransition(() => {
+ router.push(href)
+ })
+ }
- return (
-
- {hasGap ? : null}
-
- {pageNumber}
-
-
- )
- })}
-
- = totalPages}
- className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
- />
-
-
-
+ return (
+
+
+ {isPending ? 'Carregando página…' : ''}
+
+
+
+
+ interceptNavigation(event, hrefForPage(Math.max(1, page - 1)))}
+ aria-disabled={page <= 1}
+ className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
+ />
+
+ {visiblePages.map((pageNumber, index) => {
+ const previousPage = visiblePages[index - 1]
+ const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
+
+ return (
+
+ {hasGap ? : null}
+ interceptNavigation(event, hrefForPage(pageNumber))}
+ isActive={pageNumber === page}
+ aria-label={`Ir para a página ${pageNumber}`}
+ >
+ {pageNumber}
+
+
+ )
+ })}
+
+
+ interceptNavigation(event, hrefForPage(Math.min(totalPages, page + 1)))
+ }
+ aria-disabled={page >= totalPages}
+ className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
+ />
+
+
+
+
)
}
diff --git a/src/components/campaign/CampaignListPending.tsx b/src/components/campaign/CampaignListPending.tsx
new file mode 100644
index 00000000..447aca66
--- /dev/null
+++ b/src/components/campaign/CampaignListPending.tsx
@@ -0,0 +1,67 @@
+'use client'
+
+import {
+ createContext,
+ useContext,
+ useTransition,
+ type ReactNode,
+ type TransitionStartFunction,
+} from 'react'
+
+import { cn } from '@/lib/utils'
+
+type CampaignListPendingValue = {
+ isPending: boolean
+ startTransition: TransitionStartFunction
+}
+
+const CampaignListPendingContext = createContext(null)
+
+/**
+ * Shares one transition between the filter controls (which navigate) and the
+ * results region (which must dim while the RSC payload loads) — "Feel the
+ * action": optimistic on the control, honest pending on the result.
+ *
+ * Children stay server-rendered (composition via `children`).
+ */
+export const CampaignListPendingBoundary = ({ children }: { children: ReactNode }) => {
+ const [isPending, startTransition] = useTransition()
+
+ return (
+
+ {children}
+
+ )
+}
+
+/** Filter controls prefer the shared transition so the results dim with them. */
+export const useCampaignListPending = (): CampaignListPendingValue | null =>
+ useContext(CampaignListPendingContext)
+
+/** Wraps the server-rendered results; dims while a shared navigation is pending. */
+export const CampaignListResults = ({
+ children,
+ className,
+}: {
+ children: ReactNode
+ className?: string
+}) => {
+ const shared = useContext(CampaignListPendingContext)
+ const isPending = shared?.isPending ?? false
+
+ return (
+
+
+ {isPending ? 'Atualizando resultados…' : ''}
+
+ {children}
+
+ )
+}
diff --git a/src/components/campaign/CampaignSearchForm.tsx b/src/components/campaign/CampaignSearchForm.tsx
new file mode 100644
index 00000000..fad28534
--- /dev/null
+++ b/src/components/campaign/CampaignSearchForm.tsx
@@ -0,0 +1,62 @@
+'use client'
+
+import { useRouter } from 'next/navigation'
+import { useState, useTransition } from 'react'
+
+import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
+import { Button } from '@/components/ui/button'
+
+/**
+ * Name-search form for simple list pages (lideranças, organizações,
+ * dobradinhas): keeps the input value locally (optimistic) and navigates in a
+ * transition so the results region dims instead of a dead full-page wait.
+ */
+export const CampaignSearchForm = ({
+ ariaLabel,
+ placeholder,
+ initialQuery,
+ hrefForQuery,
+}: {
+ ariaLabel: string
+ placeholder: string
+ initialQuery: string
+ hrefForQuery: (query: string) => string
+}) => {
+ const router = useRouter()
+ const shared = useCampaignListPending()
+ const [isLocalPending, startLocalTransition] = useTransition()
+ const isPending = shared?.isPending ?? isLocalPending
+ const startTransition = shared?.startTransition ?? startLocalTransition
+ const [query, setQuery] = useState(initialQuery)
+
+ return (
+
+ )
+}
diff --git a/src/components/campaign/CampaignSidebar.tsx b/src/components/campaign/CampaignSidebar.tsx
index 75c70bdc..db4b6e34 100644
--- a/src/components/campaign/CampaignSidebar.tsx
+++ b/src/components/campaign/CampaignSidebar.tsx
@@ -1,6 +1,6 @@
'use client'
-import type { FormEvent } from 'react'
+import { useState, type FormEvent } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
@@ -10,6 +10,7 @@ import { CampaignScopeBadge } from '@/components/campaign/CampaignScopeBadge'
import { CampaignUserAvatar } from '@/components/campaign/CampaignUserAvatar'
import { getCampaignNav, isCampaignNavActive } from '@/components/campaign/nav'
import { Button } from '@/components/ui/button'
+import { Spinner } from '@/components/ui/Spinner'
import {
Sidebar,
SidebarContent,
@@ -32,11 +33,15 @@ export type CampaignSidebarUser = CampaignUserShellView
export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
const pathname = usePathname()
const { isMobile, setOpenMobile } = useSidebar()
+ const [isLoggingOut, setIsLoggingOut] = useState(false)
const handleLogout = async (event: FormEvent) => {
event.preventDefault()
+ if (isLoggingOut) return
+ setIsLoggingOut(true)
clearRecentVisits()
await clearCampaignPwaCaches()
+ // logoutCampaign redirects — no need to reset the pending flag on success.
await logoutCampaign()
}
@@ -53,7 +58,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
- {campaignRoleLabels[user.role]}
+
+ {campaignRoleLabels[user.role]}
+
{getCampaignNav(user.role).map((item) => (
@@ -83,7 +90,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
>
- {user.name}
+
+ {user.name}
+
Meu perfil
@@ -92,9 +101,11 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
type="submit"
variant="outline"
size="sm"
+ disabled={isLoggingOut}
className="min-h-11 w-full border-sidebar-border bg-sidebar font-medium text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
- Sair
+ {isLoggingOut ? : null}
+ {isLoggingOut ? 'Saindo…' : 'Sair'}
diff --git a/src/components/campaign/CandidateComparePicker.tsx b/src/components/campaign/CandidateComparePicker.tsx
index 814236e9..e060cccd 100644
--- a/src/components/campaign/CandidateComparePicker.tsx
+++ b/src/components/campaign/CandidateComparePicker.tsx
@@ -2,6 +2,7 @@
import { XIcon } from 'lucide-react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
+import { useTransition } from 'react'
import { Badge } from '@/components/ui/Badge'
import { Field, FieldLabel } from '@/components/ui/field'
@@ -23,6 +24,7 @@ export const CandidateComparePicker = ({
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
+ const [isPending, startTransition] = useTransition()
const optionByNumber = new Map(options.map((option) => [option.candidateNumber, option]))
const navigate = (numbers: number[]) => {
@@ -30,11 +32,20 @@ export const CandidateComparePicker = ({
params.delete('compare')
for (const number of numbers) params.append('compare', String(number))
params.set('tab', 'elections')
- router.replace(`${pathname}?${params.toString()}`, { scroll: false })
+ startTransition(() => {
+ router.replace(`${pathname}?${params.toString()}`, { scroll: false })
+ })
}
return (
-
+
+
+ {isPending ? 'Atualizando comparativo…' : ''}
+
Comparar com outros candidatos
{
const router = useRouter()
const [search, setSearch] = useState(state.q ?? '')
- const [isPending, startTransition] = useTransition()
+ const sharedPending = useCampaignListPending()
+ const [isLocalPending, startLocalTransition] = useTransition()
+ // Prefer the page-level boundary so the results region dims together.
+ const isPending = sharedPending?.isPending ?? isLocalPending
+ const startTransition = sharedPending?.startTransition ?? startLocalTransition
const debounceRef = useRef | null>(null)
useEffect(
@@ -54,7 +59,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
page: 1,
q: normalizedText(patch.q !== undefined ? patch.q : search),
}
- if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 })) return
+ if (buildMunicipalityFiltersKey(merged) === buildMunicipalityFiltersKey({ ...state, page: 1 }))
+ return
startTransition(() => {
router.replace(buildMunicipalityListHref(merged, 1), { scroll: false })
@@ -73,12 +79,12 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
const hasActiveFilters = Boolean(
state.q ||
- normalizedText(search) ||
- state.region ||
- state.kind ||
- state.coverage ||
- state.priority ||
- state.trend,
+ normalizedText(search) ||
+ state.region ||
+ state.kind ||
+ state.coverage ||
+ state.priority ||
+ state.trend,
)
return (
@@ -140,7 +146,9 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
className="min-h-11 w-full"
>
Todos
- {(Object.keys(municipalityKindLabels) as Array).map((kind) => (
+ {(
+ Object.keys(municipalityKindLabels) as Array
+ ).map((kind) => (
{municipalityKindLabels[kind]}
@@ -156,7 +164,8 @@ export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFil
value={state.coverage ?? ''}
onChange={(event) =>
commitNavigation({
- coverage: (event.target.value || undefined) as MunicipalityListState['coverage'],
+ coverage: (event.target.value ||
+ undefined) as MunicipalityListState['coverage'],
})
}
className="min-h-11 w-full"
diff --git a/src/components/campaign/MunicipalityMapPanel.tsx b/src/components/campaign/MunicipalityMapPanel.tsx
index 5b7d4b1b..3cc7f1c7 100644
--- a/src/components/campaign/MunicipalityMapPanel.tsx
+++ b/src/components/campaign/MunicipalityMapPanel.tsx
@@ -2,7 +2,7 @@
import Link from 'next/link'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
-import { useCallback, useMemo, useRef, useState } from 'react'
+import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
import { BahiaMap, type BahiaMapFeatureInfo } from '@/components/campaign/BahiaMap'
import { ChoroplethLegend } from '@/components/campaign/ChoroplethLegend'
@@ -90,7 +90,10 @@ export const MunicipalityMapPanel = ({
return computeChoroplethMax(displayValues, fillMode, percentScaleActive ? 1 : undefined)
}, [comparisonActive, displayValues, percentScaleActive])
- const scopedKeys = useMemo(() => Object.keys(bundle.municipalitiesByIbgeCode), [bundle.municipalitiesByIbgeCode])
+ const scopedKeys = useMemo(
+ () => Object.keys(bundle.municipalitiesByIbgeCode),
+ [bundle.municipalitiesByIbgeCode],
+ )
const metricLabel = useMemo(() => {
if (comparisonActive) {
@@ -107,11 +110,26 @@ export const MunicipalityMapPanel = ({
return `votos de ${bundle.candidateName} em ${year}`
}, [bundle.candidateName, comparisonActive, estimateScenario, percentScaleActive, year])
+ // Optimistic on the control, honest pending on the map: the comparison data
+ // is a server round trip, so the select flips at once and the map region
+ // dims until the refreshed bundle arrives.
+ const [isComparePending, startCompareTransition] = useTransition()
+ const [optimisticCompare, setOptimisticCompare] = useState(null)
+ const compareSelectValue =
+ isComparePending && optimisticCompare !== null
+ ? optimisticCompare
+ : comparison
+ ? String(comparison.candidateNumber)
+ : ''
+
const setCompare = (candidateNumber: string) => {
+ setOptimisticCompare(candidateNumber)
const params = new URLSearchParams(searchParams.toString())
if (candidateNumber) params.set('compare', candidateNumber)
else params.delete('compare')
- router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
+ startCompareTransition(() => {
+ router.replace(`${pathname}${params.size ? `?${params.toString()}` : ''}`, { scroll: false })
+ })
}
const handleFeatureSelect = useCallback((info: BahiaMapFeatureInfo | null) => {
@@ -218,7 +236,7 @@ export const MunicipalityMapPanel = ({
Comparar com
setCompare(event.target.value)}
className="min-h-11 w-full"
>
@@ -237,100 +255,111 @@ export const MunicipalityMapPanel = ({
- {comparisonActive && comparison ? (
-
-
-
- +{comparison.candidateName}
-
-
-
+{bundle.candidateName}
-
-
- Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
- {comparison.candidateName} na frente. Comparação usa diferença absoluta.
-
-
- ) : displayMax > 0 ? (
- '100%' : undefined}
- />
- ) : (
- Sem dados para este ano no seu escopo.
- )}
-
- {comparison && year === 2026 ? (
-
- A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para ver
- o mapa comparativo.
+
+
+ {isComparePending ? 'Atualizando comparação…' : ''}
- ) : null}
+ {comparisonActive && comparison ? (
+
+
+
+ +{comparison.candidateName}
+
+
+
+ +{bundle.candidateName}
+
+
+
+ Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
+ {comparison.candidateName} na frente. Comparação usa diferença absoluta.
+
+
+ ) : displayMax > 0 ? (
+
'100%' : undefined}
+ />
+ ) : (
+ Sem dados para este ano no seu escopo.
+ )}
- 0 ? displayMax : undefined}
- fillMode={comparisonActive ? 'diverging' : 'sequential'}
- fitToKeys={scopedKeys}
- interactiveKeys={scopedKeys}
- selectedKey={selectedFeature?.key ?? null}
- onFeatureSelect={handleFeatureSelect}
- onFeatureActivate={handleFeatureActivate}
- ariaLabel={
- comparisonActive && comparison
- ? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
- : `Mapa da Bahia com ${metricLabel} por município`
- }
- />
-
-
-
- {bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
-
-
Praças por zona eleitoral
+ {comparison && year === 2026 ? (
- Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
- detalhadas aqui.
+ A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para
+ ver o mapa comparativo.
-
- {bundle.zoneBreakdown.map((zone) => (
- -
- 0 ? displayMax : undefined}
+ fillMode={comparisonActive ? 'diverging' : 'sequential'}
+ fitToKeys={scopedKeys}
+ interactiveKeys={scopedKeys}
+ selectedKey={selectedFeature?.key ?? null}
+ onFeatureSelect={handleFeatureSelect}
+ onFeatureActivate={handleFeatureActivate}
+ ariaLabel={
+ comparisonActive && comparison
+ ? `Mapa comparativo entre ${bundle.candidateName} e ${comparison.candidateName} por município`
+ : `Mapa da Bahia com ${metricLabel} por município`
+ }
+ />
+
+
+
+ {bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
+
+
Praças por zona eleitoral
+
+ Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
+ detalhadas aqui.
+
+
+ {bundle.zoneBreakdown.map((zone) => (
+ -
- {zone.name}
-
-
- {formatElectionNumber(
- year === 2026
- ? (zone.votes2026ByScenario[estimateScenario] ?? 0)
- : (zone.votesByYear[String(year)] ?? 0),
- )}
-
-
- ))}
-
-
- ) : null}
+
+ {zone.name}
+
+
+ {formatElectionNumber(
+ year === 2026
+ ? (zone.votes2026ByScenario[estimateScenario] ?? 0)
+ : (zone.votesByYear[String(year)] ?? 0),
+ )}
+
+
+ ))}
+
+
+ ) : null}
+
)
}
diff --git a/src/components/campaign/SupporterFilters.tsx b/src/components/campaign/SupporterFilters.tsx
index e3c330c9..01151ab6 100644
--- a/src/components/campaign/SupporterFilters.tsx
+++ b/src/components/campaign/SupporterFilters.tsx
@@ -1,5 +1,6 @@
'use client'
+import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { type FormEvent, useRef, useState, useTransition } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { ChevronDownIcon, FilterIcon, XIcon } from 'lucide-react'
@@ -111,7 +112,11 @@ export const SupporterFilters = ({
const valuesRef = useRef(initialValues)
const [values, setValues] = useState(initialValues)
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false)
- const [isPending, startTransition] = useTransition()
+ const sharedPending = useCampaignListPending()
+ const [isLocalPending, startLocalTransition] = useTransition()
+ // Prefer the page-level boundary so the results region dims together.
+ const isPending = sharedPending?.isPending ?? isLocalPending
+ const startTransition = sharedPending?.startTransition ?? startLocalTransition
const replaceValues = (nextValues: FilterValues) => {
valuesRef.current = nextValues
@@ -138,7 +143,9 @@ export const SupporterFilters = ({
}
const clearFilters = () => {
- const cleared = Object.fromEntries(filterNames.map((name) => [name, ''])) as Partial
+ const cleared = Object.fromEntries(
+ filterNames.map((name) => [name, '']),
+ ) as Partial
replaceValues({ ...valuesRef.current, ...cleared })
}