-
-
Meu perfil
-
- Gerencie sua foto, sua senha e a entrada por biometria.
-
-
-
{passwordResetBanner ? (
Senha redefinida com sucesso. Você já está autenticado.
diff --git a/src/components/campaign/dashboard/CampaignDashboard.tsx b/src/components/campaign/dashboard/CampaignDashboard.tsx
index ba45b3ba..a4ddd9d1 100644
--- a/src/components/campaign/dashboard/CampaignDashboard.tsx
+++ b/src/components/campaign/dashboard/CampaignDashboard.tsx
@@ -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
}) => (
-
-
{mapSlot}
(
-
-
{municipalityOptions.length === 0 ? (
Você ainda não está vinculada a nenhum município. Fale com a assessoria da campanha.
diff --git a/src/components/campaign/shell/CampaignDesktopHeader.tsx b/src/components/campaign/shell/CampaignDesktopHeader.tsx
new file mode 100644
index 00000000..14e7c889
--- /dev/null
+++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx
@@ -0,0 +1,16 @@
+'use client'
+
+import { CampaignNotificationBellSlot } from '@/components/campaign/shell/CampaignNotificationBellSlot'
+import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
+import { SidebarTrigger } from '@/components/ui/Sidebar'
+import type { CampaignUser } from '@/payload-types'
+
+export const CampaignDesktopHeader = ({ user }: { user: CampaignUser }) => (
+
+)
diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx
index 9f3b37c7..25d1bd68 100644
--- a/src/components/campaign/shell/CampaignMobileTopBar.tsx
+++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx
@@ -5,6 +5,7 @@ import type { ReactNode } from 'react'
import { useCampaignListTransition } from '@/components/campaign/shared/CampaignListPending'
import { CampaignWizardNavLink } from '@/components/campaign/shared/CampaignWizardNavLink'
+import { CampaignPageChromeDisplay } from '@/components/campaign/shell/CampaignPageChromeDisplay'
import { useCampaignHomeSearchChrome } from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext'
import { Button } from '@/components/ui/button'
@@ -148,10 +149,7 @@ export const CampaignMobileTopBar = ({ notificationBell }: { notificationBell?:
) : (
)}
-
- Jorge Solla
- Campanha · Bahia
-
+
{notificationBell ? {notificationBell}
: null}
)
diff --git a/src/components/campaign/shell/CampaignPageChromeContext.tsx b/src/components/campaign/shell/CampaignPageChromeContext.tsx
new file mode 100644
index 00000000..1f9b84b7
--- /dev/null
+++ b/src/components/campaign/shell/CampaignPageChromeContext.tsx
@@ -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>
+}
+
+const CampaignPageChromeContext = createContext(null)
+
+export const CampaignPageChromeProvider = ({
+ role,
+ children,
+}: {
+ role: CampaignUser['role']
+ children: ReactNode
+}) => {
+ const [override, setOverride] = useState(null)
+ const value = useMemo(() => ({ role, override, setOverride }), [role, override])
+
+ return (
+
+ {children}
+
+ )
+}
+
+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
+}
diff --git a/src/components/campaign/shell/CampaignPageChromeDisplay.tsx b/src/components/campaign/shell/CampaignPageChromeDisplay.tsx
new file mode 100644
index 00000000..6599388e
--- /dev/null
+++ b/src/components/campaign/shell/CampaignPageChromeDisplay.tsx
@@ -0,0 +1,32 @@
+'use client'
+
+import { usePathname } from 'next/navigation'
+
+import { CampaignPageChromeText } from '@/components/campaign/shell/CampaignPageChromeText'
+import {
+ useCampaignPageChromeOverride,
+ useCampaignPageChromeRole,
+} from '@/components/campaign/shell/CampaignPageChromeContext'
+import { resolveCampaignPageChrome } from '@/lib/campaignPageChrome'
+
+export 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
+}
diff --git a/src/components/campaign/shell/CampaignPageChromeText.tsx b/src/components/campaign/shell/CampaignPageChromeText.tsx
new file mode 100644
index 00000000..062033d3
--- /dev/null
+++ b/src/components/campaign/shell/CampaignPageChromeText.tsx
@@ -0,0 +1,38 @@
+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 (
+
+ {chrome.title}
+ {chrome.subtitle ? (
+ {chrome.subtitle}
+ ) : null}
+
+ )
+ }
+
+ return (
+
+ {chrome.title}
+ {chrome.subtitle ? (
+ {chrome.subtitle}
+ ) : null}
+
+ )
+}
diff --git a/src/lib/campaignPageChrome.ts b/src/lib/campaignPageChrome.ts
new file mode 100644
index 00000000..4b9600f3
--- /dev/null
+++ b/src/lib/campaignPageChrome.ts
@@ -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 = {
+ 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))
diff --git a/tests/e2e/campaignConcepts.e2e.spec.ts b/tests/e2e/campaignConcepts.e2e.spec.ts
index 4627793b..53d55e06 100644
--- a/tests/e2e/campaignConcepts.e2e.spec.ts
+++ b/tests/e2e/campaignConcepts.e2e.spec.ts
@@ -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)
})
})
diff --git a/tests/e2e/campaignLeaderships.e2e.spec.ts b/tests/e2e/campaignLeaderships.e2e.spec.ts
index fd2fc58b..18595c32 100644
--- a/tests/e2e/campaignLeaderships.e2e.spec.ts
+++ b/tests/e2e/campaignLeaderships.e2e.spec.ts
@@ -1,4 +1,4 @@
-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 +25,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 +71,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.
diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts
index b710bf9e..b2b50f61 100644
--- a/tests/e2e/campaignMunicipalities.e2e.spec.ts
+++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts
@@ -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),
diff --git a/tests/e2e/campaignTerritories.e2e.spec.ts b/tests/e2e/campaignTerritories.e2e.spec.ts
index 44865fde..129aa278 100644
--- a/tests/e2e/campaignTerritories.e2e.spec.ts
+++ b/tests/e2e/campaignTerritories.e2e.spec.ts
@@ -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/)
diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts
index 643da103..baa661b3 100644
--- a/tests/e2e/fixtures/campaignE2EFixtures.ts
+++ b/tests/e2e/fixtures/campaignE2EFixtures.ts
@@ -561,4 +561,7 @@ export const test = base.extend({
},
})
+export const campaignPageChrome = (page: Page, title: string) =>
+ page.locator('[data-slot="campaign-page-chrome"]').getByText(title, { exact: true })
+
export { expect }
diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx
index 56d61633..c7d02eb4 100644
--- a/tests/unit/campaignMobileTopBar.unit.spec.tsx
+++ b/tests/unit/campaignMobileTopBar.unit.spec.tsx
@@ -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(),
@@ -13,6 +16,7 @@ import {
useSetCampaignHomeSearchChrome,
type CampaignHomeSearchChromeState,
} from '@/components/campaign/shell/CampaignHomeSearchChromeContext'
+import { CampaignPageChromeProvider } from '@/components/campaign/shell/CampaignPageChromeContext'
import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar'
import {
CampaignWizardChromeProvider,
@@ -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(
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
,
)
+}
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(
-
-
-
-
-
-
+
+
+
+
+
+
+
+
,
)
- expect(screen.queryByText('Jorge Solla')).toBeNull()
+ expect(screen.queryByText('Municípios')).toBeNull()
rerender(
-
-
-
-
-
-
+
+
+
+
+
+
+
+
,
)
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()
})
})
diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts
new file mode 100644
index 00000000..c3275d3d
--- /dev/null
+++ b/tests/unit/campaignPageChrome.unit.spec.ts
@@ -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')
+ })
+})