Merge pull request #270 from fsolla/cursor/pool-i250-d89b6314-599f

B123 — Campanha — orientação na shell + aba (sem títulos grandes de seção)
This commit is contained in:
Francisco Solla 2026-08-02 16:16:58 -03:00 committed by GitHub
commit 7839a88a5a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
57 changed files with 998 additions and 475 deletions

View file

@ -129,7 +129,7 @@ jobs:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm exec knip
- run: pnpm knip
- name: Cancel workflow on failure
if: failure()
uses: ./.github/actions/cancel-workflow-on-failure

View file

@ -38,7 +38,7 @@ jobs:
- run: pnpm lint
- run: pnpm format:check
- run: pnpm typecheck
- run: pnpm exec knip
- run: pnpm knip
- run: pnpm check:cycles
- run: pnpm test:unit
- name: Cancel workflow on failure

View file

@ -0,0 +1,80 @@
# Impl: Orientação na shell + aba — sem títulos grandes de seção
Status: aprovado
Atualizado em: 2026-08-02
Issue: #250
Intenção: docs/plans/orientacao-shell-sem-titulos-secao.md
Appetite restante: herdado (~1–1,5 dia eng)
## Leitura da intenção
- **Outcome:** orientação “onde estou” sai do corpo da página e vai para chrome estável (header mobile/desktop + aba `Solla - Campanha - <Página>`); corpo começa direto no trabalho; detalhe mantém h1 da entidade; Início sem título no header.
- **O que NÃO negociar:** wizard/auth/convite/offline intocados; h1 de entidade em detalhe; leader lockdown; chips de universo removidos (não no header).
- **O que reavaliar:** hipótese de `CampaignListPageHeader` — não existe; B118 nunca landou. Pathname registry + context override é mais maintainable que 15 nested layouts.
## Abordagem recomendada
```mermaid
flowchart LR
subgraph server [Server RSC]
P[page.tsx] --> M[export metadata via campaignPageMetadata]
P --> S[SetCampaignPageChrome em edit dinâmico]
end
subgraph client [Shell client]
R[resolveCampaignPageChrome pathname+role] --> C[Provider merge override]
C --> MB[CampaignMobileTopBar]
C --> DH[CampaignDesktopHeader]
end
```
**Opções consideradas:** A) nested `layout.tsx` por segmento · B) pathname registry + override context · C) só metadata sem shell
**Recomendação:** B — um owner (`campaignPageChrome.ts`), precedente wizard/home-search, role no layout, override só em edit dinâmico.
**Rejeitadas:** A (34 rotas, drift) · C (aceite exige shell)
### Componentes / mudanças
- **`campaignPageChrome.ts`** (`src/lib/`): vocabulário client-safe; `resolveCampaignPageChrome`; `campaignPageMetadata`
- **`CampaignPageChromeContext.tsx`**: provider + `SetCampaignPageChrome` (override, layout effect cleanup)
- **`CampaignPageChromeResolver.tsx`**: client; pathname + role → base chrome
- **`CampaignDesktopHeader.tsx`**: uma linha título+subtítulo md+
- **`CampaignMobileTopBar.tsx`**: app mode lê chrome efetivo; home vazio
- **`(app)/layout.tsx`**: provider + resolver + desktop header; passa `role`
- **`(campaign)/layout.tsx`**: `title.template: 'Solla - Campanha - %s'`
- **Páginas/componentes**: remover headers de seção/create; manter h1 entidade + back em detalhe; `SetCampaignPageChrome` em edit; metadata em rotas
- **Migration:** sem migration
- **Access / Consent:** inalterado
### Dados → forma
- Orientação = texto estático por rota + role (quadro); sem dados de Payload no resolver.
## Fases verificáveis
1. **Infra** — lib + context + shell headers + metadata template
2. **Páginas** — strip body headers; metadata; edit bridges
3. **Gates** — `pnpm gate:fast`; `pnpm push`
## Rabbit holes / Não escopo (engenharia)
- Reintroduzir chips de escopo no header
- Mexer em wizard chrome
- h2 internos (ex. “Meus contatos (N)”)
## Riscos e mitigação
- E2E busca `heading` level 1 → atualizar para shell slot ou manter entity h1 only
- Home search focus: chrome vazio em `/campanha` mesmo focado
## Aceite de engenharia
- [x] Aceite de produto da intenção ainda coberto
- [x] Invariantes AGENTS/engineering-standards
- [x] Testes unit mobile top bar + metadata resolver previstos
## Débitos deferidos (/simplify)
- **Registry único** (pathRules + metadata por página): defer — gatilho quando 3ª rota dinâmica exigir override; hoje catálogo + metadata explícita é legível.
- **Resolver único no provider** (evitar dupla resolução mobile/desktop): defer — custo negligível (~28 regex); revisit se bundle crescer.
- **Desktop header RSC** (slot bell sem client boundary): **corrigido** nesta entrega — `notificationBell` slot como mobile.
- **Subtitle desktop truncado** em uma linha: defer — validar com usuários; mobile já usa duas linhas.
- **Table-driven pathRules tests**: defer — cobertura smoke suficiente para B123; expandir se matcher quebrar de novo.

View file

@ -77,7 +77,7 @@ const main = async () => {
console.log('\n[gate:ci] ⊘ typecheck/knip/cycles skipped (no code surface)')
} else {
run('typecheck', 'pnpm', ['typecheck'])
run('knip', 'pnpm', ['exec', 'knip'])
run('knip', 'pnpm', ['knip'])
run('check:cycles', 'pnpm', ['check:cycles'])
}

View file

@ -5,7 +5,6 @@ import { notFound, redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { ConsentText } from '@/components/campaign/shared/ConsentText'
import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton'
import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit'
@ -13,8 +12,8 @@ import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntent
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import {
loadSupporterDetailConsentData,
loadSupporterDetailPageData,
@ -26,6 +25,8 @@ import {
} from '@/utilities/supporter/supporterUi'
import { parseSupporterId } from '@/utilities/supporter/supporterViewModels'
export const metadata = campaignPageMetadataFromCatalog('apoiadores')
type SupporterDetailPageProps = {
params: Promise<{ id: string }>
}
@ -71,7 +72,6 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag
Voltar para apoiadores
</Link>
</Button>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">{supporter.name}</h1>
<p className="text-muted-foreground">
{supporter.city ?? 'Cidade não informada'}

View file

@ -1,28 +1,23 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterImportWizard } from '@/components/campaign/supporter/SupporterImportWizard'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
export const metadata = campaignPageMetadataFromCatalog('apoiadoresImportar')
export default async function ImportSupportersPage() {
const user = await requireCampaignPageActor()
if (!isCampaignCoordinator(user)) redirect('/campanha/apoiadores')
return (
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>Importação · Coordenador Geral</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Importar apoiadores via CSV</h1>
<p className="text-muted-foreground">
Envie uma planilha, confira a prévia e confirme a importação em lote.
</p>
</header>
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<SupporterImportWizard />
</div>

View file

@ -4,14 +4,15 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { createSupporterFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/novo/formActions'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { SupporterForm } from '@/components/campaign/supporter/SupporterForm'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupporterCreatePageData } from '@/utilities/supporter/supporterPageData'
import { canAccessSupporterArea } from '@/utilities/supporter/supporterUi'
export const metadata = campaignPageMetadataFromCatalog('apoiadoresNovo')
export default async function NewSupporterPage() {
const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })])
@ -21,16 +22,9 @@ export default async function NewSupporterPage() {
return (
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6">
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<CampaignScopeBadge>{campaignRoleLabels[user.role]}</CampaignScopeBadge>
<h1 className="text-2xl font-semibold tracking-tight">Novo apoiador</h1>
<p className="text-muted-foreground">
Cadastre um apoiador com telefone obrigatório e consentimento LGPD.
</p>
</header>
<Button asChild variant="ghost" className="w-fit px-0">
<Link href="/campanha/apoiadores">← Voltar para apoiadores</Link>
</Button>
<SupporterForm
action={createSupporterFormAction}

View file

@ -10,25 +10,25 @@ import { getPayload } from 'payload'
import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignListEmptyState'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { SupporterFilters } from '@/components/campaign/supporter/SupporterFilters'
import { SupporterList } from '@/components/campaign/supporter/SupporterList'
import { SupporterListOverview } from '@/components/campaign/supporter/SupporterListOverview'
import { Button } from '@/components/ui/button'
import { isCampaignCoordinator, isCampaignUnrestricted } from '@/utilities/campaignAccess'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignCoordinator } from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { campaignRoleLabels } from '@/utilities/campaignUserProfile'
import { loadSupportersPageData } from '@/utilities/supporter/supporterPageData'
import {
buildSupporterFiltersKey,
buildSupporterListHref,
canAccessSupporterArea,
getSupporterScopeLabel,
} from '@/utilities/supporter/supporterUi'
import { toSupporterListItemViewModel } from '@/utilities/supporter/supporterViewModels'
export const metadata = campaignPageMetadataFromCatalog('apoiadores')
type SupportersPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -80,35 +80,22 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Apoiadores</h1>
<p className="text-muted-foreground">
Base nominal de apoio com intenção de voto e vínculo opcional a municípios.
</p>
<CampaignScopeBadge>
{isCampaignUnrestricted(user)
? `${campaignRoleLabels[user.role]} · todos os apoiadores`
: getSupporterScopeLabel(result.totalDocs)}
</CampaignScopeBadge>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{isCampaignCoordinator(user) ? (
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores/importar">
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
Importar CSV
</Link>
</Button>
) : null}
<Button asChild className="min-h-11">
<Link href="/campanha/apoiadores/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
{isCampaignCoordinator(user) ? (
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/apoiadores/importar">
<FileUpIcon data-icon="inline-start" aria-hidden="true" />
Importar CSV
</Link>
</Button>
</div>
</header>
) : null}
<Button asChild className="min-h-11">
<Link href="/campanha/apoiadores/novo">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Novo
</Link>
</Button>
</div>
<CampaignListPendingBoundary>
<SupporterFilters

View file

@ -9,12 +9,15 @@ import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/Adviso
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBrazilianPhoneInput } from '@/lib/phone'
import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor'
import { loadAdvisorDetail } from '@/utilities/advisorData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions'
export const metadata = campaignPageMetadataFromCatalog('assessores')
type AdvisorDetailPageProps = {
params: Promise<{ id: string }>
}

View file

@ -11,6 +11,7 @@ import {
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignSearchForm } from '@/components/campaign/shared/CampaignSearchForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
advisorListHrefForPage,
loadAdvisorListPageData,
@ -26,6 +27,8 @@ import {
updateAdvisorProfileFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('assessores')
type AdvisorsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -46,14 +49,6 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps)
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Assessores</h1>
<p className="text-muted-foreground">
Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha;
e-mail/celular copiam; município abre o município.
</p>
</header>
<CampaignListPendingBoundary>
<CampaignSearchForm
ariaLabel="Buscar assessor por nome ou e-mail"

View file

@ -8,6 +8,8 @@ import {
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { updateActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { ActivityNotFoundError, getActivityEditPageData } from '@/utilities/activityPageData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
@ -20,6 +22,26 @@ type EditActivityPageProps = {
params: Promise<{ slug: string }>
}
const activityEditSubtitle = 'Atualize os detalhes da atividade sem alterar o título original.'
export async function generateMetadata({ params }: EditActivityPageProps) {
const { slug } = await params
if (!slug)
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
getPayload({ config }),
])
try {
const view = await getActivityEditPageData(payload, user, slug)
return campaignPageMetadata({ title: `Editar ${view.title}`, subtitle: activityEditSubtitle })
} catch {
return campaignPageMetadata({ title: 'Editar atividade', subtitle: activityEditSubtitle })
}
}
export default async function EditActivityPage({ params }: EditActivityPageProps) {
const [{ slug }, user, payload] = await Promise.all([
params,
@ -41,13 +63,9 @@ export default async function EditActivityPage({ params }: EditActivityPageProps
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-primary">Atividades</p>
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.title}</h1>
<p className="text-muted-foreground">
Atualize os detalhes da atividade sem alterar o título original.
</p>
</header>
<SetCampaignPageChrome
chrome={{ title: `Editar ${view.title}`, subtitle: activityEditSubtitle }}
/>
<ActivityForm
action={updateActivityFormAction}
activity={view}

View file

@ -17,6 +17,7 @@ import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/Ca
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import { activityKindLabels } from '@/lib/schemas/activity'
import { getActivityDetailPageData } from '@/utilities/activityDetailPageData'
@ -34,6 +35,8 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { ActivityOverviewTab } from './ActivityOverviewTab'
export const metadata = campaignPageMetadataFromCatalog('atividades')
type ActivityDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>

View file

@ -1,6 +1,5 @@
import config from '@payload-config'
import { MapPinnedIcon } from 'lucide-react'
import type { Metadata } from 'next'
import Link from 'next/link'
import { getPayload } from 'payload'
@ -14,6 +13,7 @@ import { CalendarPhaseNote } from '@/components/campaign/tour/CalendarPhaseNote'
import { TourComposerForm, type TourStopOption } from '@/components/campaign/tour/TourComposerForm'
import { TourRegionPicker } from '@/components/campaign/tour/TourRegionPicker'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDayLabel } from '@/lib/campaignTime'
import { formatElectionNumber } from '@/lib/electionFormat'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -31,10 +31,7 @@ import {
import { createTourDraftsFormAction } from './formActions'
export const metadata: Metadata = {
title: 'Planejar giro | Campanha',
robots: { index: false, follow: false },
}
export const metadata = campaignPageMetadataFromCatalog('atividadesGiros')
type TourComposerPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
@ -113,19 +110,11 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Planejar giro</h1>
<p className="max-w-prose text-muted-foreground">
Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para
receber, e uma semente de expansão para o giro não repetir só a base. A proposta é
sugestão — você edita antes de gerar.
</p>
</div>
<div className="flex justify-end">
<Button asChild variant="outline" className="min-h-11">
<Link href="/campanha/atividades">Voltar para Atividades</Link>
</Button>
</header>
</div>
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */}
<CampaignListPendingBoundary>

View file

@ -7,6 +7,7 @@ import {
} from '@/app/(campaign)/campanha/(app)/atividades/contactSearchActions'
import { createActivityFormAction } from '@/app/(campaign)/campanha/(app)/atividades/formActions'
import { ActivityForm } from '@/components/campaign/activity/ActivityForm'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
@ -14,6 +15,8 @@ import {
} from '@/utilities/campaignRelationOptions'
import { getEligibleAdvisorOptions } from '@/utilities/municipality/municipalityViewModels'
export const metadata = campaignPageMetadataFromCatalog('atividadesNova')
export default async function NewActivityPage() {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff', redirectTo: '/campanha/atividades' }),
@ -29,13 +32,6 @@ export default async function NewActivityPage() {
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
<header className="flex flex-col gap-2">
<p className="text-sm font-medium text-primary">Atividades</p>
<h1 className="text-2xl font-semibold tracking-tight">Nova atividade</h1>
<p className="text-muted-foreground">
Defina a ação, quando e onde ela acontece e quem responde por ela.
</p>
</header>
<ActivityForm
action={createActivityFormAction}
municipalityOptions={municipalityOptions}

View file

@ -14,6 +14,7 @@ import { CampaignListEmptyState } from '@/components/campaign/shared/CampaignLis
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { Activity } from '@/payload-types'
import { loadActivityListPageData } from '@/utilities/activityPageData'
import {
@ -27,6 +28,8 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { TOUR_COMPOSER_PATH } from '@/utilities/visit/visitPlannerUrl'
export const metadata = campaignPageMetadataFromCatalog('atividades')
type ActivityListPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -53,32 +56,22 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Atividades</h1>
<p className="text-muted-foreground">
Organize caminhadas, comícios, panfletagens e demais ações de campanha.
</p>
{canCreate ? (
<div className="flex flex-wrap justify-end gap-2">
<Button asChild variant="outline" className="min-h-11">
<Link href={TOUR_COMPOSER_PATH}>
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
</div>
{canCreate ? (
<div className="flex flex-wrap gap-2">
{/* E13: the planner generates several drafts at once, so it sits next
to "Nova atividade" rather than replacing it. */}
<Button asChild variant="outline" className="min-h-11">
<Link href={TOUR_COMPOSER_PATH}>
<MapPinnedIcon data-icon="inline-start" aria-hidden="true" />
Planejar giro
</Link>
</Button>
<Button asChild className="min-h-11">
<Link href="/campanha/atividades/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova atividade
</Link>
</Button>
</div>
) : null}
</header>
) : null}
<CampaignListPendingBoundary>
<ActivityFilters

View file

@ -6,15 +6,10 @@ import {
campaignConceptsByCategory,
type CampaignIntelligenceConcept,
} from '@/lib/campaignIntelligenceConcepts'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
export const metadata: Metadata = {
title: 'Conceitos de inteligência | Campanha',
robots: {
index: false,
follow: false,
},
}
export const metadata: Metadata = campaignPageMetadataFromCatalog('conceitos')
/**
* One documented number: what it measures, how it is calculated, why it
@ -65,14 +60,6 @@ export default async function CampaignConceptsPage() {
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Conceitos de inteligência</h1>
<p className="max-w-prose text-muted-foreground">
O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje
— a lista cresce conforme novas análises entram.
</p>
</header>
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_15rem] lg:items-start lg:gap-10">
{/*
Desktop-only index: on a phone it would push all seven sections below

View file

@ -3,12 +3,15 @@ import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderContactsPanel'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { isCampaignLeader } from '@/utilities/campaignAccess'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData'
export const dynamic = 'force-dynamic'
export const metadata = campaignPageMetadataFromCatalog('contatos')
export default async function CampaignContatosPage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])
@ -20,7 +23,6 @@ export default async function CampaignContatosPage() {
return (
<LeaderContactsPanel
userName={user.name}
municipalityOptions={view.municipalityOptions}
defaultMunicipalityId={view.defaultMunicipalityId}
showMunicipalitySelect={view.showMunicipalitySelect}

View file

@ -9,6 +9,7 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
@ -23,6 +24,8 @@ import {
transitionDemandFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('demandas')
type DemandDetailPageProps = {
params: Promise<{ slug: string }>
}

View file

@ -7,12 +7,15 @@ import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/dem
import { DemandForm } from '@/components/campaign/demand/DemandForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions'
import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createDemandFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('demandasNova')
type NewDemandPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -38,19 +41,12 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/demandas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para demandas
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova demanda</h1>
<p className="text-muted-foreground">
Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria
revisa e responde por aqui.
</p>
</header>
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/demandas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para demandas
</Link>
</Button>
<DemandForm
municipalityOptions={municipalityOptions}

View file

@ -14,6 +14,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
campaignDemandKindLabels,
campaignDemandStatusLabels,
@ -29,6 +30,8 @@ import {
} from '@/utilities/campaignDemandData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
export const metadata = campaignPageMetadataFromCatalog('demandas')
type DemandsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -100,11 +103,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
])
const state = parseDemandListParams(rawSearchParams)
const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData(
payload,
user,
state,
)
const { rows, totalDocs, totalPages } = await loadDemandListPageData(payload, user, state)
const columnVisibility = await readCampaignColumnVisibility('demandas')
const hrefForStatus = (status?: CampaignDemandStatus) =>
@ -112,24 +111,14 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) {
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Demandas</h1>
<p className="text-muted-foreground">
Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando
preciso — decididas pelo Coordenador Geral.
</p>
<Badge variant="estimate-pending" className="w-fit">
{openCount} {openCount === 1 ? 'demanda em aberto' : 'demandas em aberto'}
</Badge>
</div>
<div className="flex justify-end">
<Button asChild className="min-h-11">
<Link href="/campanha/demandas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova demanda
</Link>
</Button>
</header>
</div>
<CampaignListPendingBoundary>
<CampaignFilterChips

View file

@ -8,10 +8,13 @@ import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyDetail } from '@/utilities/stateDeputyData'
import { updateStateDeputyFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
type StateDeputyDetailPageProps = {
params: Promise<{ slug: string }>
}

View file

@ -4,27 +4,23 @@ import Link from 'next/link'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { createStateDeputyFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('dobradinhasNova')
export default async function NewStateDeputyPage() {
await requireCampaignPageActor({ gate: 'staff' })
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/dobradinhas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para dobradinhas
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova dobradinha</h1>
<p className="text-muted-foreground">
Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças
nas fichas correspondentes.
</p>
</header>
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/dobradinhas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para dobradinhas
</Link>
</Button>
<StateDeputyForm formAction={createStateDeputyFormAction} />
</CampaignPageShell>

View file

@ -34,6 +34,7 @@ import {
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { MunicipalityPortfolioIndexEntry } from '@/lib/municipalityPortfolio'
import { cn } from '@/lib/utils'
import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess'
@ -64,6 +65,8 @@ import {
setStateDeputyMunicipalitiesFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('dobradinhas')
type StateDeputiesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -223,21 +226,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Dobradinhas</h1>
<p className="text-muted-foreground">
Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto
na lista.
</p>
</div>
<div className="flex justify-end">
<Button asChild className="min-h-11">
<Link href="/campanha/dobradinhas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova dobradinha
</Link>
</Button>
</header>
</div>
<CampaignListPendingBoundary>
<StateDeputyFilters

View file

@ -10,20 +10,17 @@ import {
type BiometricEnrollmentOffer,
} from '@/components/campaign/shell/BiometricEnrollmentToast'
import { CampaignAppScrollChrome } from '@/components/campaign/shell/CampaignAppScrollChrome'
import { CampaignDesktopHeader } from '@/components/campaign/shell/CampaignDesktopHeader'
import { CampaignHomeSearchChromeProvider } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignQuickActionContextProvider } from '@/components/campaign/shell/CampaignQuickActionContext'
import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar'
import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast'
import {
SIDEBAR_COOKIE_NAME,
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from '@/components/ui/Sidebar'
import { SIDEBAR_COOKIE_NAME, SidebarInset, SidebarProvider } from '@/components/ui/Sidebar'
import { Toaster } from '@/components/ui/Toaster'
import { TooltipProvider } from '@/components/ui/tooltip'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
@ -75,36 +72,37 @@ export default async function CampaignAppLayout({ children }: { children: React.
<CampaignSidebarViewportDefault hasSidebarCookie={hasSidebarCookie} />
<CampaignSidebar user={campaignUserShellView(user)} />
<SidebarInset className="h-svh min-h-0 overflow-hidden print:h-auto print:overflow-visible">
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<CampaignQuickActionContextProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
<header className="hidden min-h-11 shrink-0 items-center gap-2 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<div className="ml-auto">
<CampaignNotificationBellSlot user={user} />
</div>
</header>
{/*
<CampaignPageChromeProvider role={user.role}>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<CampaignQuickActionContextProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
<CampaignDesktopHeader
notificationBell={<CampaignNotificationBellSlot user={user} />}
/>
{/*
Provider must wrap CampaignAppScrollChrome, not only page children:
the quick-actions FAB overlay (B126) mounts as a sibling of
the scrollport and renders search hits with CampaignHoverTooltip
(priority flag). Nested only around {children} left focus→suggest
without a provider and crashed the page (B102).
*/}
<TooltipProvider delayDuration={300}>
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? <BiometricEnrollmentToast {...biometricEnrollment} /> : null}
</CampaignListPendingBoundary>
</CampaignQuickActionContextProvider>
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
<TooltipProvider delayDuration={300}>
<CampaignAppScrollChrome role={user.role}>{children}</CampaignAppScrollChrome>
</TooltipProvider>
<Toaster position="top-center" />
<InstallPwaToast />
{biometricEnrollment ? (
<BiometricEnrollmentToast {...biometricEnrollment} />
) : null}
</CampaignListPendingBoundary>
</CampaignQuickActionContextProvider>
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarInset>
</SidebarProvider>
)

View file

@ -12,6 +12,7 @@ import { CampaignQuickActionContextSync } from '@/components/campaign/shell/Camp
import { StateDeputyChips } from '@/components/campaign/stateDeputy/StateDeputyChips'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
@ -23,6 +24,8 @@ import { loadLeadershipDetail } from '@/utilities/leadership/leadershipData'
import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex'
import { updateLeadershipInternalFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancas')
type LeadershipDetailPageProps = {
params: Promise<{ id: string }>
}

View file

@ -6,6 +6,7 @@ import { getPayload } from 'payload'
import { LeadershipForm } from '@/components/campaign/leadership/LeadershipForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
loadMunicipalityOptions,
@ -14,6 +15,8 @@ import {
} from '@/utilities/campaignRelationOptions'
import { createLeadershipFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancasNova')
type NewLeadershipPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -46,19 +49,12 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para lideranças
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova liderança</h1>
<p className="text-muted-foreground">
O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de
inserir dados reais.
</p>
</header>
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/liderancas">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para lideranças
</Link>
</Button>
<LeadershipForm
municipalityOptions={municipalityOptions}

View file

@ -41,6 +41,7 @@ import {
EmptyTitle,
} from '@/components/ui/Empty'
import { resolveVisibleColumns } from '@/lib/campaignColumnVisibility'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { formatBahiaDateTimeLabel } from '@/lib/campaignTime'
import {
resolvedPortfolioEntriesById,
@ -79,6 +80,8 @@ import {
setLeadershipStateDeputyMembershipFormAction,
} from './formActions'
export const metadata = campaignPageMetadataFromCatalog('liderancas')
type LeadershipsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -381,20 +384,14 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Lideranças</h1>
<p className="text-muted-foreground">
Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.
</p>
</div>
<div className="flex justify-end">
<Button asChild className="min-h-11">
<Link href="/campanha/liderancas/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova liderança
</Link>
</Button>
</header>
</div>
<CampaignListPendingBoundary>
<LeadershipFilters state={state} municipalityLabelsById={municipalityLabelsById} />

View file

@ -6,8 +6,10 @@ import { getPayload } from 'payload'
import { MunicipalityAdvisorsForm } from '@/components/campaign/municipality/MunicipalityAdvisorsForm'
import { MunicipalityStrategyForm } from '@/components/campaign/municipality/MunicipalityStrategyForm'
import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadStateDeputyOptions } from '@/utilities/campaignRelationOptions'
import {
@ -27,6 +29,29 @@ type MunicipalityEditPageProps = {
params: Promise<{ slug: string }>
}
const municipalityEditSubtitle =
'A geografia do município é pré-definida e não pode ser alterada. Aqui você edita metas, votos estimados, inteligência e tendência política.'
export async function generateMetadata({ params }: MunicipalityEditPageProps) {
const { slug } = await params
const payload = await getPayload({ config })
const user = await requireCampaignPageActor({
gate: 'staff',
redirectTo: `/campanha/municipios/${slug}`,
})
try {
const context = await resolveAccessibleMunicipalityContext(payload, user, slug)
const view = await getMunicipalityDetailViewModel(payload, context, user)
return campaignPageMetadata({
title: `Editar ${view.name}`,
subtitle: municipalityEditSubtitle,
})
} catch {
return campaignPageMetadata({ title: 'Editar município', subtitle: municipalityEditSubtitle })
}
}
export default async function MunicipalityEditPage({ params }: MunicipalityEditPageProps) {
const { slug } = await params
const [user, payload] = await Promise.all([
@ -51,19 +76,15 @@ export default async function MunicipalityEditPage({ params }: MunicipalityEditP
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href={`/campanha/municipios/${view.slug}`}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para {view.name}
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Editar {view.name}</h1>
<p className="text-muted-foreground">
A geografia do município é pré-definida e não pode ser alterada. Aqui você edita metas,
votos estimados, inteligência e tendência política.
</p>
</header>
<SetCampaignPageChrome
chrome={{ title: `Editar ${view.name}`, subtitle: municipalityEditSubtitle }}
/>
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href={`/campanha/municipios/${view.slug}`}>
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para {view.name}
</Link>
</Button>
{user.role === 'coordinator' ? (
<MunicipalityAdvisorsForm

View file

@ -8,6 +8,7 @@ import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitT
import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import {
resolveMunicipalityDetailTab,
@ -33,6 +34,8 @@ import {
UpdatesTab,
} from './MunicipalityDetailTabs'
export const metadata = campaignPageMetadataFromCatalog('municipios')
type MunicipalityDetailPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<MunicipalityDetailSearchParams>

View file

@ -12,8 +12,8 @@ import { MunicipalityFilters } from '@/components/campaign/municipality/Municipa
import { MunicipalityList } from '@/components/campaign/municipality/MunicipalityList'
import { MunicipalityListOverview } from '@/components/campaign/municipality/MunicipalityListOverview'
import { CampaignListFooter } from '@/components/campaign/shared/CampaignListFooter'
import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import {
isCampaignCoordinator,
isCampaignStaff,
@ -21,7 +21,6 @@ import {
} from '@/utilities/campaignAccess'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { getCampaignScopeLabel } from '@/utilities/municipality/municipalityLabels'
import {
buildMunicipalityListVisitHref,
buildMunicipalityListVisitLabel,
@ -37,6 +36,8 @@ import {
} from '@/utilities/municipality/municipalityViewModels'
import { createMunicipalityListSignalFormAction } from './municipalityStaffFormActions'
export const metadata = campaignPageMetadataFromCatalog('municipios')
type MunicipalitiesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -59,7 +60,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
municipalities: listMunicipalities,
totalDocs,
totalPages,
scopeTotal,
overview,
filterFacets,
} = pageBundle
@ -166,15 +166,6 @@ export default async function MunicipalitiesPage({ searchParams }: Municipalitie
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Municípios</h1>
<p className="text-muted-foreground">
Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral
cada.
</p>
<CampaignScopeBadge>{getCampaignScopeLabel(user.role, scopeTotal)}</CampaignScopeBadge>
</header>
{main}
{listVisitLabel ? (
<RecentVisitTracker

View file

@ -9,12 +9,15 @@ import { OrganizationForm } from '@/components/campaign/organization/Organizatio
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { loadOrganizationDetail } from '@/utilities/organizationData'
import { updateOrganizationFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('organizacoes')
type OrganizationDetailPageProps = {
params: Promise<{ slug: string }>
}

View file

@ -6,10 +6,13 @@ import { getPayload } from 'payload'
import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityOptions } from '@/utilities/campaignRelationOptions'
import { createOrganizationFormAction } from './formActions'
export const metadata = campaignPageMetadataFromCatalog('organizacoesNova')
export default async function NewOrganizationPage() {
const [user, payload] = await Promise.all([
requireCampaignPageActor({ gate: 'staff' }),
@ -20,19 +23,12 @@ export default async function NewOrganizationPage() {
return (
<CampaignPageShell>
<header className="flex flex-col gap-2">
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/organizacoes">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para organizações
</Link>
</Button>
<h1 className="text-2xl font-semibold tracking-tight">Nova organização</h1>
<p className="text-muted-foreground">
Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e
atividades.
</p>
</header>
<Button asChild variant="ghost" className="min-h-11 self-start">
<Link href="/campanha/organizacoes">
<ArrowLeftIcon data-icon="inline-start" aria-hidden="true" />
Voltar para organizações
</Link>
</Button>
<OrganizationForm
municipalityOptions={municipalityOptions}

View file

@ -14,6 +14,7 @@ import { CampaignTable, type CampaignTableColumn } from '@/components/campaign/s
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/button'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { organizationKindLabels } from '@/lib/schemas/organization'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -24,6 +25,8 @@ import {
type OrganizationRowViewModel,
} from '@/utilities/organizationData'
export const metadata = campaignPageMetadataFromCatalog('organizacoes')
type OrganizationsPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -74,21 +77,14 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP
return (
<CampaignPageShell>
<header className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Organizações</h1>
<p className="text-muted-foreground">
Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e
atividades.
</p>
</div>
<div className="flex justify-end">
<Button asChild className="min-h-11">
<Link href="/campanha/organizacoes/nova">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Nova organização
</Link>
</Button>
</header>
</div>
<CampaignListPendingBoundary>
<CampaignSearchForm

View file

@ -8,6 +8,7 @@ import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/Campaig
import { CampaignHomeSummary } from '@/components/campaign/dashboard/CampaignHomeSummary'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { UNCOVERED_MUNICIPALITIES_LIST_HREF } from '@/lib/campaignHomeActions'
import { campaignPageMetadata } from '@/lib/campaignPageChrome'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -15,6 +16,8 @@ import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearch
export const dynamic = 'force-dynamic'
export const metadata = campaignPageMetadata(null)
export default async function CampaignHomePage() {
const [payload, user] = await Promise.all([getPayload({ config }), requireCampaignPageActor()])

View file

@ -1,5 +1,4 @@
import config from '@payload-config'
import type { Metadata } from 'next'
import { headers } from 'next/headers'
import { redirect } from 'next/navigation'
import { getPayload } from 'payload'
@ -7,6 +6,7 @@ import { getPayload } from 'payload'
import { CampaignPasskeysCard } from '@/components/campaign/auth/CampaignPasskeysCard'
import { CampaignProfileSettings } from '@/components/campaign/auth/CampaignProfileSettings'
import { CampaignPushNotificationsCard } from '@/components/campaign/auth/CampaignPushNotificationsCard'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { deviceLabelFromUserAgent } from '@/lib/deviceLabel'
import { getCampaignUserWithAvatar } from '@/utilities/campaignAuth'
import { getCampaignPushConsent } from '@/utilities/campaignConsent'
@ -15,13 +15,7 @@ import { getCampaignVapidPublicKey } from '@/utilities/notification/sendCampaign
import { loadCampaignPasskeys } from '@/utilities/webauthn/campaignWebAuthnCeremony'
import { resolveCampaignWebAuthnRelyingParty } from '@/utilities/webauthn/campaignWebAuthnConfig'
export const metadata: Metadata = {
title: 'Meu perfil | Campanha',
robots: {
index: false,
follow: false,
},
}
export const metadata = campaignPageMetadataFromCatalog('perfil')
type CampaignProfilePageProps = {
searchParams: Promise<{ passwordReset?: string }>

View file

@ -9,6 +9,7 @@ import {
SuggestionSilenceStrip,
SuggestionsPanel,
} from '@/components/campaign/suggestion/SuggestionsPanel'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import type { CampaignUser } from '@/payload-types'
import { getCampaignDashboardData } from '@/utilities/campaignDashboardData'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -18,6 +19,8 @@ import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipali
import { resolveSuggestionFormAction } from '../suggestionFormActions'
export const metadata = campaignPageMetadataFromCatalog('quadro')
export const dynamic = 'force-dynamic'
type CampaignQuadroPageProps = {
@ -36,7 +39,6 @@ export default async function CampaignQuadroPage({ searchParams }: CampaignQuadr
return (
<CampaignDashboard
view={view}
userName={user.name}
mapSlot={
<Suspense
fallback={

View file

@ -10,6 +10,7 @@ import {
CampaignListResults,
} from '@/components/campaign/shared/CampaignListPending'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome'
import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOverview'
@ -19,6 +20,8 @@ import {
} from '@/utilities/territory/territoryListUrl'
import { filterTerritoryRows, sortTerritoryRows } from '@/utilities/territory/territoryOverview'
export const metadata = campaignPageMetadataFromCatalog('territorios')
type TerritoriesPageProps = {
searchParams: Promise<Record<string, string | string[] | undefined>>
}
@ -44,15 +47,6 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP
return (
<CampaignPageShell>
<header className="flex max-w-prose flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight text-balance">
Territórios de Identidade
</h1>
<p className="text-muted-foreground text-pretty">
Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um
território para ver seus municípios.
</p>
</header>
<CampaignListPendingBoundary>
<TerritoryFilters state={state} regionOptions={regionOptions} />
<CampaignListResults>

View file

@ -10,7 +10,10 @@ import '../(frontend)/styles.css'
const inter = Inter({ subsets: ['latin'], variable: '--font-sans' })
export const metadata: Metadata = {
title: 'Campanha',
title: {
default: 'Campanha',
template: 'Solla - Campanha - %s',
},
robots: {
index: false,
follow: false,

View file

@ -64,13 +64,6 @@ export const CampaignProfileSettings = ({
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Meu perfil</h1>
<p className="text-sm text-muted-foreground">
Gerencie sua foto, sua senha e a entrada por biometria.
</p>
</div>
{passwordResetBanner ? (
<p className="rounded-md border border-border bg-muted/50 px-4 py-3 text-sm" role="status">
Senha redefinida com sucesso. Você já está autenticado.

View file

@ -56,27 +56,16 @@ const buildZoneCityHrefs = (
export const CampaignDashboard = ({
view,
userName,
mapSlot = null,
suggestionsSlot = null,
}: {
view: StaffDashboardView
userName: string
/** Server-streamed map section (composition keeps this component map-agnostic). */
mapSlot?: ReactNode
/** Server-streamed suggestion queue (E11) — right after the KPIs: it is the "onde ajo". */
suggestionsSlot?: ReactNode
}) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
<p className="text-muted-foreground">
{view.role === 'advisor'
? 'Quadro dos municípios sob sua assessoria.'
: 'Quadro geral da campanha por município.'}
</p>
</header>
{mapSlot}
<section

View file

@ -20,7 +20,6 @@ const dateFormatter = new Intl.DateTimeFormat('pt-BR', {
})
type LeaderContactsPanelProps = {
userName: string
municipalityOptions: RelationOption[]
defaultMunicipalityId: number | null
showMunicipalitySelect: boolean
@ -93,7 +92,6 @@ const LeaderContactsList = ({ contacts }: { contacts: LeaderContactListItem[] })
* (`LeaderContactForm` is the client island).
*/
export const LeaderContactsPanel = ({
userName,
municipalityOptions,
defaultMunicipalityId,
showMunicipalitySelect,
@ -101,13 +99,6 @@ export const LeaderContactsPanel = ({
contacts,
}: LeaderContactsPanelProps) => (
<CampaignPageShell>
<header className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold tracking-tight">Olá, {userName}</h1>
<p className="text-muted-foreground">
Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.
</p>
</header>
{municipalityOptions.length === 0 ? (
<p className="rounded-xl border px-4 py-6 text-sm text-muted-foreground">
Você ainda não está vinculada a nenhum município. Fale com a assessoria da campanha.

View file

@ -0,0 +1,14 @@
'use client'
import type { ReactNode } from 'react'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { SidebarTrigger } from '@/components/ui/Sidebar'
export const CampaignDesktopHeader = ({ notificationBell }: { notificationBell?: ReactNode }) => (
<header className="hidden min-h-11 shrink-0 items-center gap-3 border-b border-border px-4 md:flex print:hidden">
<SidebarTrigger />
<CampaignPageChromeDisplay layout="desktop" className="flex-1" />
{notificationBell ? <div className="ml-auto shrink-0">{notificationBell}</div> : null}
</header>
)

View file

@ -6,6 +6,7 @@ import type { ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
import { SidebarTrigger } from '@/components/ui/Sidebar'
@ -148,10 +149,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
) : (
<SidebarTrigger className="text-primary-foreground" />
)}
<div className="min-w-0 flex-1 leading-tight">
<span className="block truncate text-sm font-semibold">Jorge Solla</span>
<span className="block truncate text-xs text-primary-foreground/80">Campanha · Bahia</span>
</div>
<CampaignPageChromeDisplay layout="mobile" />
{notificationBell ? <div className="shrink-0">{notificationBell}</div> : null}
</header>
)

View file

@ -0,0 +1,71 @@
'use client'
import {
createContext,
useContext,
useLayoutEffect,
useMemo,
useState,
type Dispatch,
type ReactNode,
type SetStateAction,
} from 'react'
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
import type { CampaignUser } from '@/payload-types'
type CampaignPageChromeContextValue = {
role: CampaignUser['role']
override: CampaignPageChrome | null
setOverride: Dispatch<SetStateAction<CampaignPageChrome | null>>
}
const CampaignPageChromeContext = createContext<CampaignPageChromeContextValue | null>(null)
export const CampaignPageChromeProvider = ({
role,
children,
}: {
role: CampaignUser['role']
children: ReactNode
}) => {
const [override, setOverride] = useState<CampaignPageChrome | null>(null)
const value = useMemo(() => ({ role, override, setOverride }), [role, override])
return (
<CampaignPageChromeContext.Provider value={value}>
{children}
</CampaignPageChromeContext.Provider>
)
}
const useCampaignPageChromeContext = (): CampaignPageChromeContextValue => {
const value = useContext(CampaignPageChromeContext)
if (!value) {
throw new Error('CampaignPageChromeProvider is required')
}
return value
}
export const useCampaignPageChromeRole = (): CampaignUser['role'] =>
useCampaignPageChromeContext().role
export const useCampaignPageChromeOverride = (): CampaignPageChrome | null =>
useCampaignPageChromeContext().override
export const SetCampaignPageChrome = ({ chrome }: { chrome: CampaignPageChrome }): null => {
const { setOverride } = useCampaignPageChromeContext()
useLayoutEffect(() => {
setOverride(chrome)
}, [setOverride, chrome])
useLayoutEffect(
() => () => {
setOverride(null)
},
[setOverride],
)
return null
}

View file

@ -0,0 +1,32 @@
'use client'
import { usePathname } from 'next/navigation'
import {
useCampaignPageChromeOverride,
useCampaignPageChromeRole,
} from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignPageChromeText } from '@/components/campaign/shell/CampaignPageChromeText'
import { resolveCampaignPageChrome } from '@/lib/campaignPageChrome'
const useEffectiveCampaignPageChrome = () => {
const pathname = usePathname()
const role = useCampaignPageChromeRole()
const override = useCampaignPageChromeOverride()
return override ?? resolveCampaignPageChrome(pathname, role)
}
export const CampaignPageChromeDisplay = ({
layout,
className,
}: {
layout: 'mobile' | 'desktop'
className?: string
}) => {
const chrome = useEffectiveCampaignPageChrome()
if (!chrome) return null
return <CampaignPageChromeText chrome={chrome} layout={layout} className={className} />
}

View file

@ -0,0 +1,35 @@
import type { CampaignPageChrome } from '@/lib/campaignPageChrome'
import { cn } from '@/lib/utils'
export const CampaignPageChromeText = ({
chrome,
layout,
className,
}: {
chrome: CampaignPageChrome
layout: 'mobile' | 'desktop'
className?: string
}) => {
if (layout === 'desktop') {
return (
<p
data-slot="campaign-page-chrome"
className={cn('min-w-0 truncate text-sm font-medium text-foreground', className)}
>
{chrome.title}
{chrome.subtitle ? (
<span className="font-normal text-muted-foreground"> {chrome.subtitle}</span>
) : null}
</p>
)
}
return (
<div data-slot="campaign-page-chrome" className={cn('min-w-0 flex-1 leading-tight', className)}>
<span className="block truncate text-sm font-semibold">{chrome.title}</span>
{chrome.subtitle ? (
<span className="block truncate text-xs text-primary-foreground/80">{chrome.subtitle}</span>
) : null}
</div>
)
}

View file

@ -0,0 +1,305 @@
import type { Metadata } from 'next'
import { CAMPAIGN_HOME, LEADER_CONTACTS_HOME } from '@/lib/campaignPaths'
import type { CampaignUser } from '@/payload-types'
export type CampaignPageChrome = {
title: string
subtitle?: string
}
type CampaignRole = CampaignUser['role']
const normalizePathname = (pathname: string): string => {
if (pathname.length > 1 && pathname.endsWith('/')) {
return pathname.slice(0, -1)
}
return pathname
}
const quadroSubtitle = (role: CampaignRole): string =>
role === 'advisor'
? 'Quadro dos municípios sob sua assessoria.'
: 'Quadro geral da campanha por município.'
/** Client-safe vocabulary for shell chrome and tab titles (B123). */
export const campaignPageChromeCatalog = {
home: null,
quadro: {
title: 'Quadro',
subtitle: quadroSubtitle,
},
municipios: {
title: 'Municípios',
subtitle:
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
},
territorios: {
title: 'Territórios de Identidade',
subtitle:
'Compare a concentração histórica e a cobertura de assessoria das regiões da Bahia. Abra um território para ver seus municípios.',
},
liderancas: {
title: 'Lideranças',
subtitle:
'Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações.',
},
liderancasNova: {
title: 'Nova liderança',
subtitle:
'O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de inserir dados reais.',
},
dobradinhas: {
title: 'Dobradinhas',
subtitle:
'Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto na lista.',
},
dobradinhasNova: {
title: 'Nova dobradinha',
subtitle:
'Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças nas fichas correspondentes.',
},
atividades: {
title: 'Atividades',
subtitle: 'Organize caminhadas, comícios, panfletagens e demais ações de campanha.',
},
atividadesNova: {
title: 'Nova atividade',
subtitle: 'Defina a ação, quando e onde ela acontece e quem responde por ela.',
},
atividadesGiros: {
title: 'Planejar giro',
subtitle:
'Um giro por território: o ato onde o voto comprometido já está, as paradas com rede para receber, e uma semente de expansão para o giro não repetir só a base. A proposta é sugestão — você edita antes de gerar.',
},
demandas: {
title: 'Demandas',
subtitle:
'Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando preciso — decididas pelo Coordenador Geral.',
},
demandasNova: {
title: 'Nova demanda',
subtitle:
'Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria revisa e responde por aqui.',
},
apoiadores: {
title: 'Apoiadores',
subtitle: 'Base nominal de apoio com intenção de voto e vínculo opcional a municípios.',
},
apoiadoresNovo: {
title: 'Novo apoiador',
subtitle: 'Cadastre um apoiador com telefone obrigatório e consentimento LGPD.',
},
apoiadoresImportar: {
title: 'Importar apoiadores via CSV',
subtitle: 'Envie uma planilha, confira a prévia e confirme a importação em lote.',
},
assessores: {
title: 'Assessores',
subtitle:
'Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha; e-mail/celular copiam; município abre o município.',
},
organizacoes: {
title: 'Organizações',
subtitle:
'Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e atividades.',
},
organizacoesNova: {
title: 'Nova organização',
subtitle:
'Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e atividades.',
},
conceitos: {
title: 'Conceitos de inteligência',
subtitle:
'O que cada número da campanha mede e como é calculado. Só o que o produto já calcula hoje — a lista cresce conforme novas análises entram.',
},
contatos: {
title: 'Contatos',
subtitle: 'Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui.',
},
perfil: {
title: 'Meu perfil',
subtitle: 'Gerencie sua foto, sua senha e a entrada por biometria.',
},
} as const satisfies Record<
string,
CampaignPageChrome | { title: string; subtitle: (role: CampaignRole) => string } | null
>
const resolveCatalogEntry = (
entry: (typeof campaignPageChromeCatalog)[keyof typeof campaignPageChromeCatalog],
role: CampaignRole,
): CampaignPageChrome | null => {
if (!entry) return null
const subtitle = typeof entry.subtitle === 'function' ? entry.subtitle(role) : entry.subtitle
return { title: entry.title, subtitle }
}
const sectionOnly = (title: string): CampaignPageChrome => ({ title })
type PathRule = {
match: (pathname: string) => boolean
resolve: (role: CampaignRole) => CampaignPageChrome | null
}
const pathRules: PathRule[] = [
{
match: (pathname) => pathname === CAMPAIGN_HOME,
resolve: () => null,
},
{
match: (pathname) => pathname === '/campanha/quadro',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.quadro, role),
},
{
match: (pathname) => pathname === '/campanha/municipios',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.municipios, role),
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+\/editar$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title),
},
{
match: (pathname) => pathname === '/campanha/territorios',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.territorios, role),
},
{
match: (pathname) => pathname === '/campanha/liderancas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancasNova, role),
},
{
match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.liderancas.title),
},
{
match: (pathname) => pathname === '/campanha/liderancas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.liderancas, role),
},
{
match: (pathname) => pathname === '/campanha/dobradinhas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhasNova, role),
},
{
match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.dobradinhas.title),
},
{
match: (pathname) => pathname === '/campanha/dobradinhas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.dobradinhas, role),
},
{
match: (pathname) => pathname === '/campanha/atividades/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesNova, role),
},
{
match: (pathname) => pathname === '/campanha/atividades/giros',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividadesGiros, role),
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+\/editar$/.test(pathname),
resolve: () => null,
},
{
match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.atividades.title),
},
{
match: (pathname) => pathname === '/campanha/atividades',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.atividades, role),
},
{
match: (pathname) => pathname === '/campanha/demandas/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandasNova, role),
},
{
match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.demandas.title),
},
{
match: (pathname) => pathname === '/campanha/demandas',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.demandas, role),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/novo',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresNovo, role),
},
{
match: (pathname) => pathname === '/campanha/apoiadores/importar',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadoresImportar, role),
},
{
match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.apoiadores.title),
},
{
match: (pathname) => pathname === '/campanha/apoiadores',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.apoiadores, role),
},
{
match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.assessores.title),
},
{
match: (pathname) => pathname === '/campanha/assessores',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.assessores, role),
},
{
match: (pathname) => pathname === '/campanha/organizacoes/nova',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoesNova, role),
},
{
match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname),
resolve: () => sectionOnly(campaignPageChromeCatalog.organizacoes.title),
},
{
match: (pathname) => pathname === '/campanha/organizacoes',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.organizacoes, role),
},
{
match: (pathname) => pathname === '/campanha/conceitos',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.conceitos, role),
},
{
match: (pathname) => pathname === LEADER_CONTACTS_HOME,
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.contatos, role),
},
{
match: (pathname) => pathname === '/campanha/perfil',
resolve: (role) => resolveCatalogEntry(campaignPageChromeCatalog.perfil, role),
},
]
export const resolveCampaignPageChrome = (
pathname: string,
role: CampaignRole,
): CampaignPageChrome | null => {
const normalized = normalizePathname(pathname)
for (const rule of pathRules) {
if (rule.match(normalized)) {
return rule.resolve(role)
}
}
return null
}
const noIndexMetadata: Pick<Metadata, 'robots'> = {
robots: { index: false, follow: false },
}
/** Tab title via `title.template` on `(campaign)/layout.tsx`. */
export const campaignPageMetadata = (chrome: CampaignPageChrome | null): Metadata => {
if (!chrome) {
return { title: 'Início', ...noIndexMetadata }
}
return { title: chrome.title, ...noIndexMetadata }
}
export const campaignPageMetadataFromCatalog = (
key: keyof typeof campaignPageChromeCatalog,
role: CampaignRole = 'coordinator',
): Metadata => campaignPageMetadata(resolveCatalogEntry(campaignPageChromeCatalog[key], role))

View file

@ -14,7 +14,7 @@ import {
import { municipalityCatalog } from '@/lib/municipalityCatalog'
import { AT_STANDARD_LQ } from '@/lib/territorialClassAnchors'
import { DEFAULT_VOTE_ESTIMATE_SCENARIO, voteEstimateScenarioLabels } from '@/lib/voteEstimate'
import type { CampaignUser, Municipality } from '@/payload-types'
import type { Municipality } from '@/payload-types'
import { formatRatioAsPercentLabel } from '@/utilities/municipality/goalCoverage'
import { MUNICIPALITY_COLD_SIGNAL_DAYS } from '@/utilities/municipality/municipalitySignal'
import type {
@ -153,19 +153,6 @@ export const formatMunicipalityConcentrationHint = (
): string =>
`Percentual da votação estadual do candidato neste município — não o % dos válidos locais. Colocação: posição no catálogo de ${formatElectionNumber(totalUnits)} unidades.`
export const getCampaignScopeLabel = (
role: CampaignUser['role'],
municipalityCount: number,
): string => {
if (role === 'advisor') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município sob sua assessoria' : 'municípios sob sua assessoria'}`
}
if (role === 'leader') {
return `${municipalityCount} ${municipalityCount === 1 ? 'município em que você atua' : 'municípios em que você atua'}`
}
return `${municipalityCount} ${municipalityCount === 1 ? 'município' : 'municípios'}`
}
export const municipalityGeographyParts = (municipality: {
region: string
kind: Municipality['kind']

View file

@ -1,5 +1,5 @@
import { campaignConceptHref } from '../../src/lib/campaignIntelligenceConcepts.js'
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
/**
* E18 — `/campanha/conceitos` documents staff-only numbers (estimates, goals,
@ -41,9 +41,7 @@ test.describe('Conceitos de inteligência', () => {
await page.getByRole('link', { name: 'Como cada número é calculado' }).click()
await expect(page).toHaveURL(/\/campanha\/conceitos$/)
await expect(
page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
).toBeVisible()
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toBeVisible()
await expect(page.getByRole('heading', { name: 'Cobertura da meta' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Teto do campo (projetado)' })).toBeVisible()
@ -67,8 +65,6 @@ test.describe('Conceitos de inteligência', () => {
await page.goto('/campanha/conceitos')
await expect(page).toHaveURL(/\/campanha\/contatos\/?$/)
await expect(
page.getByRole('heading', { level: 1, name: 'Conceitos de inteligência' }),
).toHaveCount(0)
await expect(campaignPageChrome(page, 'Conceitos de inteligência')).toHaveCount(0)
})
})

View file

@ -1,4 +1,9 @@
import { expect, expectPostResponse, test } from './fixtures/campaignE2EFixtures.js'
import {
campaignPageChrome,
expect,
expectPostResponse,
test,
} from './fixtures/campaignE2EFixtures.js'
/**
* `/campanha/liderancas` journeys: the B32 support-status quick edit (Popover +
@ -25,7 +30,7 @@ test.describe('campaign leaderships list', () => {
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
await page.getByRole('button', { name: 'Editar status de apoio' }).click()
const statusPopover = page.locator('[data-slot="popover-content"]')
@ -71,7 +76,7 @@ test.describe('campaign leaderships list', () => {
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
await expect(page.getByRole('heading', { name: 'Lideranças', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
// The chip is still a link to the município — editing did not cost the
// navigation the column had before.

View file

@ -4,6 +4,7 @@ import {
SUPPORTER_REGISTRATION_CONSENT_LEASE_KEY,
} from '../helpers/testDatabaseLease.js'
import {
campaignPageChrome,
checkRadixWhenHydrated,
expect,
expectPostResponse,
@ -37,7 +38,7 @@ test.describe('Municípios — jornadas por papel', () => {
await campaign.login(page, coordinator.email!, password)
await page.goto(`${campaign.baseURL}/campanha/municipios`)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// E9 allocation queue: the list opens on the uncovered deficit and exposes
// the freshness column the ordering is paired with. `exact` keeps this off
@ -94,7 +95,7 @@ test.describe('Municípios — jornadas por papel', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
const advisorsTrigger = page.getByRole('button', { name: 'Editar assessores' })
await advisorsTrigger.click()
@ -150,7 +151,7 @@ test.describe('Municípios — jornadas por papel', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
await page.getByRole('button', { name: 'Editar tendência política' }).click()
const trendPopover = page.locator('[data-slot="popover-content"]')
@ -191,7 +192,7 @@ test.describe('Municípios — jornadas por papel', () => {
await page.goto(
`${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`,
)
await expect(page.getByRole('heading', { name: 'Municípios', exact: true })).toBeVisible()
await expect(campaignPageChrome(page, 'Municípios')).toBeVisible()
// Anchored: a bare template regex on a municipality name collides with
// prefix-shared names (Conde/Condeúba — 23/435 measured in the catalog),
@ -298,7 +299,7 @@ test.describe('Municípios — jornadas por papel', () => {
// `password` variable above does NOT unlock the leader account.
await campaign.login(page, leaderPhone, leaderAccount.password)
await page.goto(`${campaign.baseURL}/campanha/contatos`)
await expect(page.getByText('Cadastre apoiadores pelo celular.')).toBeVisible()
await expect(campaignPageChrome(page, 'Contatos')).toBeVisible()
// The leader redirect aborts `goto`'s load event (ERR_ABORTED); the
// redirect itself is the assertion, and `toHaveURL` retries onto it.

View file

@ -1,4 +1,4 @@
import { expect, test } from './fixtures/campaignE2EFixtures.js'
import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js'
test.describe('Territórios de Identidade', () => {
test('staff sorts, filters and opens the municipality queue for a territory', async ({
@ -16,9 +16,7 @@ test.describe('Territórios de Identidade', () => {
await page.getByRole('link', { name: 'Territórios', exact: true }).click()
await expect(page).toHaveURL(/\/campanha\/territorios$/)
await expect(
page.getByRole('heading', { level: 1, name: 'Territórios de Identidade' }),
).toBeVisible()
await expect(campaignPageChrome(page, 'Territórios de Identidade')).toBeVisible()
await page.getByRole('link', { name: /Ordenar por Votos 2022/ }).click()
await expect(page).toHaveURL(/\/campanha\/territorios\?sort=votes2022/)

View file

@ -561,4 +561,7 @@ export const test = base.extend<CampaignE2ETestFixtures>({
},
})
export const campaignPageChrome = (page: Page, title: string) =>
page.locator('[data-slot="campaign-page-chrome"]').filter({ visible: true }).getByText(title)
export { expect }

View file

@ -1,7 +1,10 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const pathnameState = vi.hoisted(() => ({ value: '/campanha/municipios' }))
vi.mock('next/navigation', () => ({
usePathname: () => pathnameState.value,
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
@ -14,6 +17,7 @@ import {
type CampaignHomeSearchChromeState,
} from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import {
CampaignWizardChromeProvider,
useSetCampaignWizardChrome,
@ -35,18 +39,23 @@ const HomeSearchChromeProbe = ({ chrome }: { chrome: CampaignHomeSearchChromeSta
const renderTopBar = (input: {
wizardChrome?: CampaignWizardChromeState | null
homeSearchChrome?: CampaignHomeSearchChromeState | null
}) =>
render(
pathname?: string
}) => {
pathnameState.value = input.pathname ?? '/campanha/municipios'
return render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe chrome={input.wizardChrome ?? null} />
<HomeSearchChromeProbe chrome={input.homeSearchChrome ?? null} />
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe chrome={input.wizardChrome ?? null} />
<HomeSearchChromeProbe chrome={input.homeSearchChrome ?? null} />
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
}
describe('CampaignMobileTopBar', () => {
beforeEach(() => {
@ -58,20 +67,29 @@ describe('CampaignMobileTopBar', () => {
vi.unstubAllGlobals()
})
it('renders app mode with sidebar trigger when chrome is null', () => {
it('renders page chrome from pathname in app mode', () => {
const { container } = renderTopBar({})
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(screen.getByText('Jorge Solla')).toBeTruthy()
expect(screen.getByText('Municípios')).toBeTruthy()
expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull()
expect(screen.queryByRole('button', { name: 'Fechar busca' })).toBeNull()
expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull()
})
it('renders empty chrome on home', () => {
const { container } = renderTopBar({ pathname: '/campanha' })
const topBar = container.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('app')
expect(container.querySelector('[data-slot="campaign-page-chrome"]')).toBeNull()
})
it('renders app mode with Voltar when home search is focused (B106)', () => {
const collapse = vi.fn()
const { container } = renderTopBar({
pathname: '/campanha',
homeSearchChrome: { focused: true, collapse },
})
@ -140,45 +158,49 @@ describe('CampaignMobileTopBar', () => {
it('keeps wizard mode when chrome updates between steps', () => {
const { rerender } = render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
stepKind: 'entry',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
expect(screen.queryByText('Jorge Solla')).toBeNull()
expect(screen.queryByText('Municípios')).toBeNull()
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignHomeSearchChromeProvider>
<WizardChromeProbe
chrome={{
flowTitle: 'Ajustar votos',
municipalityLabel: 'Cairu',
stepKind: 'continue',
previousHref: '/campanha/acoes/atualizar-votos',
dismissHref: '/campanha',
}}
/>
<CampaignMobileTopBar />
</CampaignHomeSearchChromeProvider>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
const topBar = document.querySelector('[data-slot="campaign-mobile-top-bar"]')
expect(topBar?.getAttribute('data-mode')).toBe('wizard')
expect(screen.queryByText('Jorge Solla')).toBeNull()
expect(screen.queryByText('Municípios')).toBeNull()
expect(screen.getByLabelText('Município em atualização: Cairu')).toBeTruthy()
})
})

View file

@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest'
import {
campaignPageMetadata,
campaignPageMetadataFromCatalog,
resolveCampaignPageChrome,
} from '@/lib/campaignPageChrome'
describe('campaignPageChrome', () => {
it('returns null for home', () => {
expect(resolveCampaignPageChrome('/campanha', 'coordinator')).toBeNull()
expect(resolveCampaignPageChrome('/campanha/', 'advisor')).toBeNull()
})
it('resolves section list chrome', () => {
expect(resolveCampaignPageChrome('/campanha/municipios', 'coordinator')).toEqual({
title: 'Municípios',
subtitle:
'Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral cada.',
})
})
it('resolves detail pages with section title only', () => {
expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({
title: 'Municípios',
})
})
it('uses role-based subtitle for quadro', () => {
expect(resolveCampaignPageChrome('/campanha/quadro', 'advisor')?.subtitle).toContain(
'sua assessoria',
)
expect(resolveCampaignPageChrome('/campanha/quadro', 'coordinator')?.subtitle).toContain(
'geral',
)
})
it('builds tab metadata from catalog', () => {
expect(campaignPageMetadataFromCatalog('contatos').title).toBe('Contatos')
expect(campaignPageMetadata(null).title).toBe('Início')
})
})

View file

@ -2,6 +2,7 @@ import { cleanup, render, screen, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('next/navigation', () => ({
usePathname: () => '/campanha/acoes/atualizar-votos',
useRouter: () => ({
push: vi.fn(),
replace: vi.fn(),
@ -10,6 +11,7 @@ vi.mock('next/navigation', () => ({
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_MUNICIPALITY_STEP_TITLE, wizardFlowChromeAriaLabel } from '@/lib/campaignWizardCopy'
@ -18,10 +20,12 @@ import { stubMatchMedia } from '../helpers/matchMedia'
const renderWizardShell = (props: React.ComponentProps<typeof CampaignWizardShell>) =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell {...props} />
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell {...props} />
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
@ -104,35 +108,39 @@ describe('CampaignWizardShell', () => {
it('skips title focus when contentFocus is none', () => {
const { rerender } = render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
contentFocus="none"
>
<input aria-label="Primeiro campo" />
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
@ -144,33 +152,37 @@ describe('CampaignWizardShell', () => {
it('moves focus to the step title when stepTitle changes', () => {
const { rerender } = render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 1"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)
rerender(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Passo 2"
isEntryStep
previousHref="/campanha"
>
Corpo
</CampaignWizardShell>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)

View file

@ -9,6 +9,7 @@ const routerState = vi.hoisted(() => ({
vi.mock('next/navigation', async (importActual) => ({
...(await importActual()),
usePathname: () => '/campanha/acoes/atualizar-votos',
useRouter: () => ({
push: (...args: unknown[]) => routerState.push(...args),
replace: vi.fn(),
@ -18,6 +19,7 @@ vi.mock('next/navigation', async (importActual) => ({
import { CampaignListPendingBoundary } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { SidebarProvider } from '@/components/ui/Sidebar'
import { WIZARD_STEP_PENDING_MESSAGE } from '@/lib/campaignWizardCopy'
@ -26,21 +28,23 @@ import { stubMatchMedia } from '../helpers/matchMedia'
const mountWizardNavigation = () =>
render(
<SidebarProvider>
<CampaignWizardChromeProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Quantos votos?"
isEntryStep={false}
previousHref="/campanha/acoes/atualizar-votos"
dismissHref="/campanha"
municipalityLabel="Cairu"
>
Corpo do passo
</CampaignWizardShell>
</CampaignListPendingBoundary>
</CampaignWizardChromeProvider>
<CampaignPageChromeProvider role="coordinator">
<CampaignWizardChromeProvider>
<CampaignListPendingBoundary>
<CampaignMobileTopBar />
<CampaignWizardShell
flowTitle="Ajustar votos"
stepTitle="Quantos votos?"
isEntryStep={false}
previousHref="/campanha/acoes/atualizar-votos"
dismissHref="/campanha"
municipalityLabel="Cairu"
>
Corpo do passo
</CampaignWizardShell>
</CampaignListPendingBoundary>
</CampaignWizardChromeProvider>
</CampaignPageChromeProvider>
</SidebarProvider>,
)