Make every campaign action feel immediate with honest pending

Closes the 'Feel the action' gaps (.cursor/rules/campanha-action-feedback.mdc):

- BUG: action-plan task checkbox was optimistic but never revalidated — a
  successful toggle visually reverted when the transition ended. The action
  now revalidates the plan page; checkbox disables while pending
- map compare + candidate compare picker: optimistic select value +
  useTransition; the map region dims with aria-busy and a live region
  instead of flashing the full-page skeleton
- CampaignListPendingBoundary/CampaignListResults: one shared transition so
  filters (municípios/apoiadores/planos), pagination, demandas status chips
  (new CampaignFilterChips), and the name-search pages (new
  CampaignSearchForm on lideranças/organizações/dobradinhas) dim the RESULTS
  region, not only the control — children stay server-rendered (composition)
- streaming: dashboard map section behind Suspense (KPI shell paints first;
  React cache() keeps the municipality scope shared, not reloaded);
  municipality detail tabs (elections/leaderships/updates) stream below the
  header; list-shaped loading.tsx for /municipios and /apoiadores
- logout button gets a pending state; BahiaMap init shows a visible spinner
  (was sr-only)
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-23 23:21:46 -03:00
parent a32ba3b4f0
commit 05a47b881c
26 changed files with 1077 additions and 616 deletions

View file

@ -13,8 +13,8 @@
| Phase | Content | Skill phases | Status | Date |
| ----- | ------- | ------------ | ------ | ---- |
| 0 | Security lockdown: collection access defaults, leader route gates, broken leader-supporter create, candidate alignment | — (P0 pre-work) | done | 2026-07-23 |
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | in-progress | 2026-07-23 |
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | pending | |
| 1 | Tooling gate: knip + dead code, CI, tsconfig, zero lint warnings, `as never` ban, docs artifacts | 1 (net) + 2 (readability) + 6 (habits) | done | 2026-07-23 |
| 2 | Caching: React `cache()` dedup, committed election-aggregate artifact, `unstable_cache`, select/depth trims | 8 (sizing) | done | 2026-07-23 |
| 3 | Loading feedback: task-toggle revalidation bug, map compare transition, result-region pending, Suspense streaming | — (UX correctness) | pending | |
| 4 | Client boundary/state: map hover isolation, bundle slimming, provider narrowing, composition splits | 4 (deep modules, UI) | pending | |
| 5 | Structure: split campaignAccess + supporter actions, dedupe clones, utilities organization, finish rename | 3 (refactoring) + 4 (deep modules) | pending | |

View file

@ -0,0 +1,20 @@
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for /apoiadores and its detail routes. */
export default function SupportersLoading() {
return (
<CampaignPageShell aria-label="Carregando apoiadores" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-5 w-80 max-w-full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-11 w-full max-w-md" />
<Skeleton className="h-11 w-44" />
</div>
<Skeleton className="h-24 w-full rounded-xl" />
<Skeleton className="h-80 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { FileUpIcon, PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -43,13 +47,47 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
const rawSearchParams = await searchParams
const now = new Date()
const { result, state, redirectHref, municipalityOptions, overview } = await loadSupportersPageData(
payload,
user,
rawSearchParams,
)
const { result, state, redirectHref, municipalityOptions, overview } =
await loadSupportersPageData(payload, user, rawSearchParams)
if (redirectHref) redirect(redirectHref)
const listBody = result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -82,48 +120,15 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
</div>
</header>
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
<CampaignListPendingBoundary>
<SupporterFilters
key={buildSupporterFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
{result.docs.length ? (
<>
{overview ? <SupporterListOverview view={overview} now={now} /> : null}
<SupporterList
supporters={result.docs.map((supporter) => toSupporterListItemViewModel(supporter))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'apoiador encontrado' : 'apoiadores encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildSupporterListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum apoiador encontrado</EmptyTitle>
<EmptyDescription>
Ajuste a busca ou os filtros. Você só vê apoiadores dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores">Limpar busca e filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>{listBody}</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -4,7 +4,12 @@ import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { CampaignFilterChips } from '@/components/campaign/CampaignFilterChips'
import { CampaignListPagination } from '@/components/campaign/CampaignListPagination'
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
@ -73,6 +78,78 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
return query ? `/campanha/demandas?${query}` : '/campanha/demandas'
}
const listBody = rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Demanda</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praça</TableHead>
<TableHead>Solicitante</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aberta em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="max-w-64 whitespace-normal">
<Link
href={`/campanha/demandas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.title}
</Link>
</TableCell>
<TableCell>{campaignDemandKindLabels[row.kind]}</TableCell>
<TableCell className="text-muted-foreground">{row.municipalityName}</TableCell>
<TableCell className="text-muted-foreground">{row.requesterName ?? '—'}</TableCell>
<TableCell>
<Badge variant={statusVariant[row.status]}>
{campaignDemandStatusLabels[row.status]}
</Badge>
</TableCell>
<TableCell>{dateFormatter.format(new Date(row.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => hrefFor(state.status, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<InboxIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma demanda por aqui</EmptyTitle>
<EmptyDescription>
Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</EmptyContent>
</Empty>
)
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
@ -94,100 +171,21 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
</Button>
</header>
<nav aria-label="Filtrar por status" className="flex flex-wrap gap-2">
<Button
asChild
variant={state.status === undefined ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={hrefFor(undefined)}>Todas</Link>
</Button>
{campaignDemandStatuses.map((status) => (
<Button
key={status}
asChild
variant={state.status === status ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={hrefFor(status)}>{campaignDemandStatusLabels[status]}</Link>
</Button>
))}
</nav>
<CampaignListPendingBoundary>
<CampaignFilterChips
ariaLabel="Filtrar por status"
chips={[
{ href: hrefFor(undefined), label: 'Todas', active: state.status === undefined },
...campaignDemandStatuses.map((status) => ({
href: hrefFor(status),
label: campaignDemandStatusLabels[status],
active: state.status === status,
})),
]}
/>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Demanda</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praça</TableHead>
<TableHead>Solicitante</TableHead>
<TableHead>Status</TableHead>
<TableHead>Aberta em</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="max-w-64 whitespace-normal">
<Link
href={`/campanha/demandas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.title}
</Link>
</TableCell>
<TableCell>{campaignDemandKindLabels[row.kind]}</TableCell>
<TableCell className="text-muted-foreground">{row.municipalityName}</TableCell>
<TableCell className="text-muted-foreground">
{row.requesterName ?? '—'}
</TableCell>
<TableCell>
<Badge variant={statusVariant[row.status]}>
{campaignDemandStatusLabels[row.status]}
</Badge>
</TableCell>
<TableCell>{dateFormatter.format(new Date(row.createdAt))}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'demanda' : 'demandas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={(page) => hrefFor(state.status, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<InboxIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma demanda por aqui</EmptyTitle>
<EmptyDescription>
Abra uma demanda quando precisar de material, transporte, espaço ou apoio para uma
ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>{listBody}</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -69,83 +74,82 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar dobradinha por nome"
placeholder="Buscar por nome…"
aria-label="Buscar dobradinha por nome"
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query ? `/campanha/dobradinhas?q=${encodeURIComponent(query)}` : '/campanha/dobradinhas'
}
/>
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
</form>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Partido</TableHead>
<TableHead>Municípios</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/dobradinhas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
<TableCell className="tabular-nums">{row.municipalityCount}</TableCell>
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'dobradinha' : 'dobradinhas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma dobradinha cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Partido</TableHead>
<TableHead>Municípios</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/dobradinhas/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell className="text-muted-foreground">{row.party ?? '—'}</TableCell>
<TableCell className="tabular-nums">{row.municipalityCount}</TableCell>
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'dobradinha' : 'dobradinhas'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma dobradinha cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre deputados estaduais parceiros para vincular a municípios e lideranças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -67,96 +72,99 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar liderança por nome"
placeholder="Buscar por nome…"
aria-label="Buscar liderança por nome"
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query ? `/campanha/liderancas?q=${encodeURIComponent(query)}` : '/campanha/liderancas'
}
/>
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
</form>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Status</TableHead>
<TableHead>Praças</TableHead>
<TableHead>Organizações</TableHead>
<TableHead>Acesso ao app</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/liderancas/${row.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
{row.supportStatus ? <SupportStatusBadge status={row.supportStatus} /> : '—'}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.municipalityNames.join(', ') || '—'}
</TableCell>
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
{row.organizationNames.join(', ') || '—'}
</TableCell>
<TableCell>
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
<EmptyDescription>
Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas
Praças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Status</TableHead>
<TableHead>Praças</TableHead>
<TableHead>Organizações</TableHead>
<TableHead>Acesso ao app</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/liderancas/${row.id}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
{row.supportStatus ? (
<SupportStatusBadge status={row.supportStatus} />
) : (
'—'
)}
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.municipalityNames.join(', ') || '—'}
</TableCell>
<TableCell className="max-w-56 whitespace-normal text-muted-foreground">
{row.organizationNames.join(', ') || '—'}
</TableCell>
<TableCell>
<Badge variant={row.hasAppAccess ? 'estimate-confirmed' : 'outline'}>
{row.hasAppAccess ? 'Com acesso' : 'Sem acesso'}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'liderança' : 'lideranças'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma liderança encontrada</EmptyTitle>
<EmptyDescription>
Cadastre a primeira liderança ou ajuste a busca. Você só vê lideranças das suas
Praças.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,3 +1,4 @@
import { Suspense } from 'react'
import config from '@payload-config'
import Link from 'next/link'
import { notFound, redirect } from 'next/navigation'
@ -111,24 +112,32 @@ export default async function MunicipalityDetailPage({
) : null}
{activeTab === 'elections' ? (
<ElectionsTab
slug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
// Streams below the header/tab chrome — the TSE baselines and the
// comparison table are the slowest reads on this page.
<Suspense fallback={<MunicipalityTabFallback />}>
<ElectionsTab
slug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
</Suspense>
) : null}
{activeTab === 'leaderships' ? (
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
<Suspense fallback={<MunicipalityTabFallback />}>
<LeadershipsTab municipalityID={view.id} payloadUser={{ payload, user }} />
</Suspense>
) : null}
{activeTab === 'updates' ? (
<UpdatesTab
municipalityID={view.id}
municipalitySlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
<Suspense fallback={<MunicipalityTabFallback />}>
<UpdatesTab
municipalityID={view.id}
municipalitySlug={view.slug}
rawSearchParams={rawSearchParams}
payloadUser={{ payload, user }}
/>
</Suspense>
) : null}
{activeTab === 'demands' ? (
@ -193,6 +202,13 @@ const OverviewTab = async ({
)
}
const MunicipalityTabFallback = () => (
<div aria-hidden="true" className="flex flex-col gap-4">
<div className="h-40 w-full animate-pulse rounded-xl border bg-muted/40" />
<div className="h-64 w-full animate-pulse rounded-xl border bg-muted/40" />
</div>
)
const ElectionsTab = async ({
slug,
rawSearchParams,

View file

@ -0,0 +1,20 @@
import { CampaignPageShell } from '@/components/campaign/CampaignPageShell'
import { Skeleton } from '@/components/ui/skeleton'
/** List-shaped skeleton for /municipios and its detail routes. */
export default function MunicipalitiesLoading() {
return (
<CampaignPageShell aria-label="Carregando municípios" aria-busy="true">
<div className="flex flex-col gap-2">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-5 w-96 max-w-full" />
</div>
<div className="flex flex-wrap gap-3">
<Skeleton className="h-11 w-full max-w-md" />
<Skeleton className="h-11 w-40" />
<Skeleton className="h-11 w-40" />
</div>
<Skeleton className="h-96 w-full rounded-xl" />
</CampaignPageShell>
)
}

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -128,16 +132,18 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
</Empty>
)
// Shared transition: the filters navigate, the results dim ("Feel the action").
const filteredMain = (
<CampaignListPendingBoundary>
{filters}
<CampaignListResults>{listBody}</CampaignListResults>
</CampaignListPendingBoundary>
)
const main = isStaffView ? (
<MunicipalityEstimateScenarioProvider>
{filters}
{listBody}
</MunicipalityEstimateScenarioProvider>
<MunicipalityEstimateScenarioProvider>{filteredMain}</MunicipalityEstimateScenarioProvider>
) : (
<>
{filters}
{listBody}
</>
filteredMain
)
return (

View file

@ -1,3 +1,8 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/CampaignSearchForm'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -72,88 +77,89 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
</Button>
</header>
<form role="search" className="flex gap-2">
<input
type="search"
name="q"
defaultValue={state.q ?? ''}
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar organização por nome"
placeholder="Buscar por nome…"
aria-label="Buscar organização por nome"
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
initialQuery={state.q ?? ''}
hrefForQuery={(query) =>
query
? `/campanha/organizacoes?q=${encodeURIComponent(query)}`
: '/campanha/organizacoes'
}
/>
<Button type="submit" variant="secondary" className="min-h-11">
Buscar
</Button>
</form>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praças de atuação</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/organizacoes/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.municipalityNames.join(', ') || '—'}
</TableCell>
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma organização cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de
Ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{rows.length ? (
<>
<div className="overflow-hidden rounded-xl border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nome</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Praças de atuação</TableHead>
<TableHead>Lideranças</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<Link
href={`/campanha/organizacoes/${row.slug}`}
className="inline-flex min-h-11 items-center font-medium text-primary underline-offset-4 hover:underline"
>
{row.name}
</Link>
</TableCell>
<TableCell>
<Badge variant="secondary">{organizationKindLabels[row.kind]}</Badge>
</TableCell>
<TableCell className="max-w-64 whitespace-normal text-muted-foreground">
{row.municipalityNames.join(', ') || '—'}
</TableCell>
<TableCell className="tabular-nums">{row.leadershipCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-sm text-muted-foreground">
{totalDocs} {totalDocs === 1 ? 'organização' : 'organizações'}
</p>
<CampaignListPagination
page={state.page}
totalPages={totalPages}
hrefForPage={hrefForPage}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhuma organização cadastrada</EmptyTitle>
<EmptyDescription>
Cadastre sindicatos, associações e movimentos para vincular lideranças e Planos de
Ação.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,9 +1,13 @@
import config from '@payload-config'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { getPayload, type Payload } from 'payload'
import { Suspense } from 'react'
import { CampaignDashboard } from '@/components/campaign/CampaignDashboard'
import { LeaderContactsPanel } from '@/components/campaign/LeaderContactsPanel'
import { MunicipalityEstimateScenarioProvider } from '@/components/campaign/MunicipalityEstimateScenarioContext'
import { MunicipalityMapPanelDynamic } from '@/components/campaign/MunicipalityMapPanelDynamic'
import type { CampaignUser } from '@/payload-types'
import { isCampaignLeader, isCampaignStaff } from '@/utilities/campaignAccess'
import { getCampaignUser } from '@/utilities/campaignAuth'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
@ -37,20 +41,50 @@ export default async function CampaignHomePage({ searchParams }: CampaignHomePag
}
const isStaff = isCampaignStaff(user)
const [view, mapBundle, candidateOptions] = await Promise.all([
getCampaignDashboardData(payload, user),
isStaff
? loadMunicipalityMapBundle(payload, user, { compare: rawSearchParams.compare })
: Promise.resolve(null),
isStaff ? loadFederalCandidateOptions(user) : Promise.resolve([]),
])
const view = await getCampaignDashboardData(payload, user)
return (
<CampaignDashboard
view={view}
userName={user.name}
mapBundle={mapBundle}
candidateOptions={candidateOptions}
mapSlot={
isStaff ? (
// Streams after the KPI shell paints; the shared request-scoped
// municipality scope is reused (React cache), not reloaded.
<Suspense
fallback={
<div
aria-hidden="true"
className="h-[420px] w-full animate-pulse rounded-xl border bg-muted/40"
/>
}
>
<DashboardMapSection payload={payload} user={user} compare={rawSearchParams.compare} />
</Suspense>
) : null
}
/>
)
}
const DashboardMapSection = async ({
payload,
user,
compare,
}: {
payload: Payload
user: CampaignUser
compare: string | string[] | undefined
}) => {
const [mapBundle, candidateOptions] = await Promise.all([
loadMunicipalityMapBundle(payload, user, { compare }),
loadFederalCandidateOptions(user),
])
if (!mapBundle) return null
return (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
)
}

View file

@ -1,5 +1,7 @@
'use server'
import { revalidatePath } from 'next/cache'
import { toggleActionPlanTask } from '@/app/(campaign)/campanha/actions/actionPlan'
export type ToggleActionPlanTaskResult = { ok: true } | { ok: false; message: string }
@ -11,6 +13,9 @@ export const toggleActionPlanTaskAction = async (
): Promise<ToggleActionPlanTaskResult> => {
try {
await toggleActionPlanTask(planId, taskId, done)
// Refresh the RSC props behind the optimistic checkbox — without this the
// optimistic state reverts to stale server data when the transition ends.
revalidatePath('/campanha/planos/[slug]', 'page')
return { ok: true }
} catch (error) {
return {

View file

@ -1,3 +1,7 @@
import {
CampaignListPendingBoundary,
CampaignListResults,
} from '@/components/campaign/CampaignListPending'
import config from '@payload-config'
import { PlusIcon, SearchXIcon } from 'lucide-react'
import Link from 'next/link'
@ -71,47 +75,51 @@ export default async function ActionPlanListPage({ searchParams }: ActionPlanLis
) : null}
</header>
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
<CampaignListPendingBoundary>
<ActionPlanFilters
key={buildActionPlanFiltersKey(state)}
state={state}
municipalityOptions={municipalityOptions}
/>
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
<CampaignListResults>
{result.docs.length ? (
<>
<ActionPlanList
plans={result.docs.map((plan) => toActionPlanListViewModel(plan as ActionPlan))}
/>
<div className="flex flex-col items-center gap-3">
<p className="text-sm text-muted-foreground">
{result.totalDocs}{' '}
{result.totalDocs === 1 ? 'plano encontrado' : 'planos encontrados'}
</p>
<CampaignListPagination
page={state.page}
totalPages={result.totalPages}
hrefForPage={(page) => buildActionPlanListHref(state, page)}
/>
</div>
</>
) : (
<Empty className="min-h-72 border">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon aria-hidden="true" />
</EmptyMedia>
<EmptyTitle>Nenhum plano encontrado</EmptyTitle>
<EmptyDescription>
Ajuste os filtros ou a janela selecionada. Você só vê planos dentro do seu escopo.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/planos">Limpar filtros</Link>
</Button>
</EmptyContent>
</Empty>
)}
</CampaignListResults>
</CampaignListPendingBoundary>
</CampaignPageShell>
)
}

View file

@ -1,5 +1,6 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { useRef, useState, useTransition } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
@ -81,7 +82,11 @@ export const ActionPlanFilters = ({
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
@ -101,7 +106,8 @@ export const ActionPlanFilters = ({
const updateKind = (kind: string) => replaceValues({ ...valuesRef.current, kind })
const updateStatus = (status: string) => replaceValues({ ...valuesRef.current, status })
const updateMunicipality = (municipality: string) => replaceValues({ ...valuesRef.current, municipality })
const updateMunicipality = (municipality: string) =>
replaceValues({ ...valuesRef.current, municipality })
const clearFilters = () => replaceValues({ kind: '', status: '', municipality: '' })

View file

@ -1,6 +1,6 @@
'use client'
import { startTransition, useOptimistic } from 'react'
import { useOptimistic, useTransition } from 'react'
import { toast } from 'sonner'
import { toggleActionPlanTaskAction } from '@/app/(campaign)/campanha/(app)/planos/[slug]/taskActions'
@ -14,6 +14,7 @@ type ActionPlanTaskChecklistProps = {
}
export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskChecklistProps) => {
const [isPending, startTransition] = useTransition()
const [optimisticTasks, setOptimisticDone] = useOptimistic(
tasks,
(currentTasks, { taskId, done }: { taskId: string; done: boolean }) =>
@ -26,6 +27,8 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
startTransition(async () => {
setOptimisticDone({ taskId, done })
const result = await toggleActionPlanTaskAction(planId, taskId, done)
// On failure the optimistic state rolls back automatically when the
// transition ends (the action does not revalidate on error).
if (!result.ok) toast.error(result.message)
})
}
@ -35,15 +38,17 @@ export const ActionPlanTaskChecklist = ({ planId, tasks }: ActionPlanTaskCheckli
}
return (
<ul role="list" className="flex flex-col gap-2">
<ul role="list" className="flex flex-col gap-2" aria-busy={isPending}>
{optimisticTasks.map((task) => (
<li
key={task.id ?? task.title}
className="flex items-start gap-3 rounded-lg border p-3"
data-done={task.done}
data-pending={isPending || undefined}
>
<Checkbox
checked={task.done}
disabled={isPending}
onCheckedChange={(checked) => toggle(task.id, checked === true)}
aria-label={`Marcar tarefa "${task.title}" como ${task.done ? 'pendente' : 'concluída'}`}
className="mt-0.5"

View file

@ -24,6 +24,7 @@ import {
type LayerStyleContext,
} from '@/lib/bahiaMapStyle'
import { cn } from '@/lib/utils'
import { Spinner } from '@/components/ui/Spinner'
export type { BahiaMapFillMode, ChoroplethValues }
@ -113,7 +114,9 @@ export const BahiaMap = ({
const containerRef = useRef<HTMLDivElement>(null)
const mapRef = useRef<L.Map | null>(null)
const layerRef = useRef<L.GeoJSON | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(null)
const geometryModuleRef = useRef<MunicipalityGeometryModule | TerritoryGeometryModule | null>(
null,
)
const pathByKeyRef = useRef<Map<string, L.Path>>(new Map())
const styleContextRef = useRef<LayerStyleContext | null>(null)
const geometryReadyRef = useRef(false)
@ -445,7 +448,14 @@ export const BahiaMap = ({
heightClassName,
)}
/>
{status === 'loading' ? <p className="sr-only">Carregando mapa…</p> : null}
{status === 'loading' ? (
<div className="absolute inset-0 flex items-center justify-center" role="status">
<span className="flex items-center gap-2 rounded-md bg-background/85 px-3 py-2 text-sm text-muted-foreground shadow-sm">
<Spinner aria-hidden="true" />
Carregando mapa…
</span>
</div>
) : null}
{status === 'error' ? (
<p className="absolute inset-0 flex items-center justify-center bg-background/80 px-4 text-center text-sm text-muted-foreground">
Não foi possível carregar o mapa. Tente recarregar a página.

View file

@ -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
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
@ -46,11 +43,7 @@ export const CampaignDashboard = ({
</p>
</header>
{mapBundle ? (
<MunicipalityEstimateScenarioProvider>
<MunicipalityMapPanelDynamic bundle={mapBundle} candidateOptions={candidateOptions} />
</MunicipalityEstimateScenarioProvider>
) : null}
{mapSlot}
<section
aria-label="Indicadores das Praças"

View file

@ -0,0 +1,77 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState, useTransition, type MouseEvent } from 'react'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { Button } from '@/components/ui/button'
export type CampaignFilterChip = {
href: string
label: string
active: boolean
}
/**
* Status/kind chips that navigate via URL. Optimistic on the chip (the tapped
* one activates at once), honest pending on the results (shared boundary
* transition dims them) — "Feel the action".
*/
export const CampaignFilterChips = ({
ariaLabel,
chips,
}: {
ariaLabel: string
chips: CampaignFilterChip[]
}) => {
const router = useRouter()
const shared = useCampaignListPending()
const [isLocalPending, startLocalTransition] = useTransition()
const isPending = shared?.isPending ?? isLocalPending
const startTransition = shared?.startTransition ?? startLocalTransition
const [optimisticHref, setOptimisticHref] = useState<string | null>(null)
const activeHref =
isPending && optimisticHref !== null
? optimisticHref
: (chips.find((chip) => chip.active)?.href ?? null)
const interceptNavigation = (event: MouseEvent<HTMLAnchorElement>, 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 (
<nav aria-label={ariaLabel} aria-busy={isPending} className="flex flex-wrap gap-2">
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
{chips.map((chip) => (
<Button
key={chip.href}
asChild
variant={chip.href === activeHref ? 'default' : 'outline'}
className="min-h-11"
>
<Link href={chip.href} onClick={(event) => interceptNavigation(event, chip.href)}>
{chip.label}
</Link>
</Button>
))}
</nav>
)
}

View file

@ -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 (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={hrefForPage(Math.max(1, page - 1))}
aria-disabled={page <= 1}
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{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<HTMLAnchorElement>, href: string) => {
if (
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
) {
return
}
event.preventDefault()
startTransition(() => {
router.push(href)
})
}
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={hrefForPage(pageNumber)}
isActive={pageNumber === page}
aria-label={`Ir para a página ${pageNumber}`}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={hrefForPage(Math.min(totalPages, page + 1))}
aria-disabled={page >= totalPages}
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
return (
<div
className="transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Carregando página…' : ''}
</p>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href={hrefForPage(Math.max(1, page - 1))}
onClick={(event) => interceptNavigation(event, hrefForPage(Math.max(1, page - 1)))}
aria-disabled={page <= 1}
className={page <= 1 ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
{visiblePages.map((pageNumber, index) => {
const previousPage = visiblePages[index - 1]
const hasGap = previousPage !== undefined && pageNumber - previousPage > 1
return (
<PaginationItem key={pageNumber} className="contents">
{hasGap ? <PaginationEllipsis /> : null}
<PaginationLink
href={hrefForPage(pageNumber)}
onClick={(event) => interceptNavigation(event, hrefForPage(pageNumber))}
isActive={pageNumber === page}
aria-label={`Ir para a página ${pageNumber}`}
>
{pageNumber}
</PaginationLink>
</PaginationItem>
)
})}
<PaginationItem>
<PaginationNext
href={hrefForPage(Math.min(totalPages, page + 1))}
onClick={(event) =>
interceptNavigation(event, hrefForPage(Math.min(totalPages, page + 1)))
}
aria-disabled={page >= totalPages}
className={page >= totalPages ? 'pointer-events-none opacity-50' : undefined}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)
}

View file

@ -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<CampaignListPendingValue | null>(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 (
<CampaignListPendingContext.Provider value={{ isPending, startTransition }}>
{children}
</CampaignListPendingContext.Provider>
)
}
/** 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 (
<div
className={cn(
'flex flex-col gap-6 transition-opacity data-[pending=true]:opacity-60',
className,
)}
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
{children}
</div>
)
}

View file

@ -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 (
<form
role="search"
className="flex gap-2 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
onSubmit={(event) => {
event.preventDefault()
startTransition(() => {
router.replace(hrefForQuery(query), { scroll: false })
})
}}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando resultados…' : ''}
</p>
<input
type="search"
name="q"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={placeholder}
aria-label={ariaLabel}
className="min-h-11 w-full max-w-md rounded-[6px] border border-input bg-background px-3 text-sm outline-none focus-visible:border-primary focus-visible:ring-[3px] focus-visible:ring-primary/30"
/>
<Button type="submit" variant="secondary" className="min-h-11" disabled={isPending}>
Buscar
</Button>
</form>
)
}

View file

@ -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<HTMLFormElement>) => {
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 }) => {
<SidebarContent>
<SidebarGroup>
<CampaignScopeBadge className="mb-3 w-fit">{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<CampaignScopeBadge className="mb-3 w-fit">
{campaignRoleLabels[user.role]}
</CampaignScopeBadge>
<SidebarGroupContent>
<SidebarMenu>
{getCampaignNav(user.role).map((item) => (
@ -83,7 +90,9 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => {
>
<CampaignUserAvatar name={user.name} avatarUrl={user.avatarUrl} size="sm" />
<div className="flex min-w-0 flex-col gap-0.5 leading-none">
<span className="truncate text-sm font-medium text-sidebar-foreground">{user.name}</span>
<span className="truncate text-sm font-medium text-sidebar-foreground">
{user.name}
</span>
<span className="truncate text-xs text-muted-foreground">Meu perfil</span>
</div>
</Link>
@ -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 ? <Spinner aria-hidden="true" /> : null}
{isLoggingOut ? 'Saindo…' : 'Sair'}
</Button>
</form>
</SidebarFooter>

View file

@ -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 (
<div className="flex flex-col gap-2">
<div
className="flex flex-col gap-2 transition-opacity data-[pending=true]:opacity-70"
data-pending={isPending || undefined}
aria-busy={isPending}
>
<p className="sr-only" aria-live="polite">
{isPending ? 'Atualizando comparativo…' : ''}
</p>
<Field className="max-w-md">
<FieldLabel htmlFor="candidate-compare-picker">Comparar com outros candidatos</FieldLabel>
<NativeSelect

View file

@ -1,5 +1,6 @@
'use client'
import { useCampaignListPending } from '@/components/campaign/CampaignListPending'
import { useRouter } from 'next/navigation'
import { useEffect, useRef, useState, useTransition } from 'react'
@ -29,7 +30,11 @@ type MunicipalityFiltersProps = {
export const MunicipalityFilters = ({ state, showStaffFilters }: MunicipalityFiltersProps) => {
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<ReturnType<typeof setTimeout> | 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"
>
<NativeSelectOption value="">Todos</NativeSelectOption>
{(Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>).map((kind) => (
{(
Object.keys(municipalityKindLabels) as Array<keyof typeof municipalityKindLabels>
).map((kind) => (
<NativeSelectOption key={kind} value={kind}>
{municipalityKindLabels[kind]}
</NativeSelectOption>
@ -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"

View file

@ -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<string | null>(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 = ({
<FieldLabel htmlFor="municipality-map-compare">Comparar com</FieldLabel>
<NativeSelect
id="municipality-map-compare"
value={comparison ? String(comparison.candidateNumber) : ''}
value={compareSelectValue}
onChange={(event) => setCompare(event.target.value)}
className="min-h-11 w-full"
>
@ -237,100 +255,111 @@ export const MunicipalityMapPanel = ({
</div>
</div>
{comparisonActive && comparison ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
+{comparison.candidateName}
</span>
<div
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
style={{ background: divergingGradientCss }}
role="img"
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
/>
<span className="shrink-0 text-xs text-muted-foreground">+{bundle.candidateName}</span>
</div>
<p className="text-xs text-muted-foreground">
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
)}
{comparison && year === 2026 ? (
<p className="text-sm text-muted-foreground">
A comparação usa os anos com dados TSE (2014, 2018, 2022). Escolha um destes anos para ver
o mapa comparativo.
<div
className="flex flex-col gap-4 transition-opacity data-[pending=true]:opacity-60"
data-pending={isComparePending || undefined}
aria-busy={isComparePending}
>
<p className="sr-only" aria-live="polite">
{isComparePending ? 'Atualizando comparação…' : ''}
</p>
) : null}
{comparisonActive && comparison ? (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="shrink-0 text-xs text-muted-foreground">
+{comparison.candidateName}
</span>
<div
className="h-2.5 min-w-0 flex-1 rounded-full ring-1 ring-foreground/10"
style={{ background: divergingGradientCss }}
role="img"
aria-label={`Escala divergente: azul onde ${comparison.candidateName} lidera, branco no empate, vermelho onde ${bundle.candidateName} lidera`}
/>
<span className="shrink-0 text-xs text-muted-foreground">
+{bundle.candidateName}
</span>
</div>
<p className="text-xs text-muted-foreground">
Vermelho: {bundle.candidateName} na frente · Branco: empate · Azul:{' '}
{comparison.candidateName} na frente. Comparação usa diferença absoluta.
</p>
</div>
) : displayMax > 0 ? (
<ChoroplethLegend
max={displayMax}
metricLabel={metricLabel}
formatMax={percentScaleActive ? () => '100%' : undefined}
/>
) : (
<p className="text-sm text-muted-foreground">Sem dados para este ano no seu escopo.</p>
)}
<BahiaMap
mode="municipality"
values={displayValues}
scaleMax={displayMax > 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`
}
/>
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
{comparison && year === 2026 ? (
<p className="text-sm text-muted-foreground">
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.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
key={zone.slug}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
<Link
href={`/campanha/municipios/${zone.slug}`}
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
) : null}
<BahiaMap
mode="municipality"
values={displayValues}
scaleMax={displayMax > 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`
}
/>
<MapFeatureReadout
feature={selectedFeature}
metricValue={selectedMetricValue}
rawMetricValue={selectedRawMetricValue}
metricLabel={metricLabel}
scaleMode={comparisonActive ? 'absolute' : scaleMode}
comparisonActive={comparisonActive}
navigation={selectedNavigation}
/>
{bundle.zoneBreakdown.length > 0 && !comparisonActive ? (
<div id="municipality-zone-breakdown" className="flex flex-col gap-2">
<h3 className="text-sm font-medium">Praças por zona eleitoral</h3>
<p className="text-sm text-muted-foreground">
Zonas não têm polígono oficial — Salvador e Camaçari aparecem agregadas no mapa e
detalhadas aqui.
</p>
<ul className="grid gap-1 sm:grid-cols-2">
{bundle.zoneBreakdown.map((zone) => (
<li
key={zone.slug}
className="flex items-center justify-between gap-2 rounded-md border px-3 py-2"
>
{zone.name}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(
year === 2026
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
: (zone.votesByYear[String(year)] ?? 0),
)}
</span>
</li>
))}
</ul>
</div>
) : null}
<Link
href={`/campanha/municipios/${zone.slug}`}
className="min-h-11 content-center text-sm font-medium text-primary underline-offset-4 hover:underline"
>
{zone.name}
</Link>
<span className="text-sm tabular-nums text-muted-foreground">
{formatElectionNumber(
year === 2026
? (zone.votes2026ByScenario[estimateScenario] ?? 0)
: (zone.votesByYear[String(year)] ?? 0),
)}
</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
}

View file

@ -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<FilterValues>
const cleared = Object.fromEntries(
filterNames.map((name) => [name, '']),
) as Partial<FilterValues>
replaceValues({ ...valuesRef.current, ...cleared })
}