diff --git a/.github/workflows/ci-pr.yml b/.github/workflows/ci-pr.yml index 24dc005d..b4f19f99 100644 --- a/.github/workflows/ci-pr.yml +++ b/.github/workflows/ci-pr.yml @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14262138..33da2960 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/docs/plans/orientacao-shell-sem-titulos-secao-impl.md b/docs/plans/orientacao-shell-sem-titulos-secao-impl.md new file mode 100644 index 00000000..7d88573f --- /dev/null +++ b/docs/plans/orientacao-shell-sem-titulos-secao-impl.md @@ -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 - `); 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. diff --git a/docs/plans/sidebar-sem-logo-escopo-impl.md b/docs/plans/sidebar-sem-logo-escopo-impl.md new file mode 100644 index 00000000..73f5d7ed --- /dev/null +++ b/docs/plans/sidebar-sem-logo-escopo-impl.md @@ -0,0 +1,65 @@ +# Impl: Sidebar /campanha — sem logo nem chip de escopo no topo + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #271 +Intenção: docs/plans/sidebar-sem-logo-escopo.md +Appetite restante: ~0,5 dia (herdado) + +## Leitura da intenção + +- **Outcome:** O topo da sidebar (desktop e Sheet mobile) não exibe logo nem badge de papel; a lista de navegação começa no topo, alinhada com o header da área principal. Login mantém logo; chips de escopo em páginas permanecem. +- **O que NÃO negociar:** `CampaignScopeBadge` e `CampaignLogo` seguem vivos fora da sidebar; leader lockdown e nav por role intactos; auth shell inalterado. +- **O que reavaliar:** A hipótese de editar só `CampaignSidebar.tsx` confirma-se — não há outro call site de logo/badge na sidebar. + +## Abordagem recomendada + +```mermaid +flowchart LR + A[CampaignSidebar] --> B[Remove SidebarHeader + logo] + A --> C[Remove CampaignScopeBadge do grupo nav] + D[CampaignAuthPageShell] --> E[logo intacto] + F[páginas de lista] --> G[CampaignScopeBadge intacto] +``` + +**Opções consideradas:** + +- A — Remover `SidebarHeader` inteiro e o badge do primeiro `SidebarGroup` em `CampaignSidebar.tsx` +- B — Esconder via CSS (`hidden`) mantendo DOM +- C — Mover logo para o rodapé + +**Recomendação:** A — remoção direta no dono; menos DOM, sem degrau vertical, sem código morto. +**Rejeitadas:** B (DOM fantasma, testes frágeis); C (fora do escopo de produto). + +### Componentes / mudanças + +- **`CampaignSidebar`** (`src/components/campaign/shell/CampaignSidebar.tsx`): remover `SidebarHeader` (logo link), `CampaignScopeBadge` e imports órfãos (`CampaignLogo`, `CampaignScopeBadge`, `SidebarHeader`, `campaignRoleLabels`). +- **Migration:** sem migration +- **Access / Consent:** N/A +- **UI:** Impeccable B — encaixe no shell; nav cola no topo do `SidebarContent`; footer com avatar/perfil inalterado. + +### Dados → forma + +N/A — chrome estrutural. + +## Fases verificáveis + +1. **UI** — editar `CampaignSidebar.tsx`; limpar imports +2. **Gates** — `pnpm gate:fast`; push via `pnpm push` + +## Rabbit holes / Não escopo (engenharia) + +- Não tocar `CampaignScopeBadge` em páginas de conteúdo +- Não alterar `CampaignAuthPageShell` nem `campaign-logo.tsx` +- Não redesenhar padding do `SidebarGroup` além do que a remoção naturalmente resolve + +## Riscos e mitigação + +- **Teste de convenção que pinne logo na sidebar:** `campaignComponents.unit.spec.ts` lê o source — verificar que pins de shell (`collapsible`, `print:hidden`, tokens) permanecem válidos. +- **Alinhamento vertical:** remover `SidebarHeader` com `border-b` elimina o degrau; header principal já é `min-h-11` — aceite visual coberto pela remoção, sem padding compensatório artificial. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit source pins existentes; sem access/write) diff --git a/scripts/gate-ci.mjs b/scripts/gate-ci.mjs index 5e867818..f670d8cd 100644 --- a/scripts/gate-ci.mjs +++ b/scripts/gate-ci.mjs @@ -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']) } diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 24b7587b..1d8b97f2 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -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 - {campaignRoleLabels[user.role]}

{supporter.name}

{supporter.city ?? 'Cidade não informada'} diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx index a0c4c811..dc0fa325 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/importar/page.tsx @@ -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 (

-
- - Importação · Coordenador Geral -

Importar apoiadores via CSV

-

- Envie uma planilha, confira a prévia e confirme a importação em lote. -

-
+
diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx index 009d9112..5d2ee1b5 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/novo/page.tsx @@ -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 (
-
- - {campaignRoleLabels[user.role]} -

Novo apoiador

-

- Cadastre um apoiador com telefone obrigatório e consentimento LGPD. -

-
+ > } @@ -80,35 +80,22 @@ export default async function SupportersPage({ searchParams }: SupportersPagePro return ( -
-
-

Apoiadores

-

- Base nominal de apoio com intenção de voto e vínculo opcional a municípios. -

- - {isCampaignUnrestricted(user) - ? `${campaignRoleLabels[user.role]} · todos os apoiadores` - : getSupporterScopeLabel(result.totalDocs)} - -
-
- {isCampaignCoordinator(user) ? ( - - ) : null} - -
-
+ ) : null} + +
} diff --git a/src/app/(campaign)/campanha/(app)/assessores/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/page.tsx index febfa822..7e360570 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/page.tsx @@ -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> } @@ -46,14 +49,6 @@ export default async function AdvisorsPage({ searchParams }: AdvisorsPageProps) return ( -
-

Assessores

-

- Consulte na tabela; ative Editar para alterar campos e carteira. Nome abre a ficha; - e-mail/celular copiam; município abre o município. -

-
- } +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 (
-
-

Atividades

-

Editar {view.title}

-

- Atualize os detalhes da atividade sem alterar o título original. -

-
+ searchParams: Promise> diff --git a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx index eac45795..92ba04f6 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/giros/page.tsx @@ -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> @@ -113,19 +110,11 @@ export default async function TourComposerPage({ searchParams }: TourComposerPag return ( -
-
-

Planejar giro

-

- 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. -

-
+
-
+
{/* Shared transition: the picker navigates, the proposal dims ("Feel the action"). */} diff --git a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx index 507666bf..1c98d78b 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/nova/page.tsx @@ -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 (
-
-

Atividades

-

Nova atividade

-

- Defina a ação, quando e onde ela acontece e quem responde por ela. -

-
> } @@ -53,32 +56,22 @@ export default async function ActivityListPage({ searchParams }: ActivityListPag return ( -
-
-

Atividades

-

- Organize caminhadas, comícios, panfletagens e demais ações de campanha. -

+ {canCreate ? ( +
+ +
- {canCreate ? ( -
- {/* E13: the planner generates several drafts at once, so it sits next - to "Nova atividade" rather than replacing it. */} - - -
- ) : null} -
+ ) : null} -
-

Conceitos de inteligência

-

- 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. -

-
-
{/* Desktop-only index: on a phone it would push all seven sections below diff --git a/src/app/(campaign)/campanha/(app)/contatos/page.tsx b/src/app/(campaign)/campanha/(app)/contatos/page.tsx index a8cc8a89..99deaba5 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -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 ( } diff --git a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx index c6c99756..91b08834 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -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> } @@ -38,19 +41,12 @@ export default async function NewDemandPage({ searchParams }: NewDemandPageProps return ( -
- -

Nova demanda

-

- Descreva a necessidade (material, transporte, espaço, apoio…) e o município. A assessoria - revisa e responde por aqui. -

-
+ > } @@ -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 ( -
-
-

Demandas

-

- Necessidades da campanha abertas pelas lideranças, revisadas pela assessoria e — quando - preciso — decididas pelo Coordenador Geral. -

- - {openCount} {openCount === 1 ? 'demanda em aberto' : 'demandas em aberto'} - -
+
-
+
} diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx index 6e09c2e7..19c8411b 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx @@ -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 ( -
- -

Nova dobradinha

-

- Cadastre deputados estaduais com quem a campanha dobra. Vincule a municípios e lideranças - nas fichas correspondentes. -

-
+
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx index c8e9a3f9..8a40c3b2 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -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> } @@ -223,21 +226,14 @@ export default async function StateDeputiesPage({ searchParams }: StateDeputiesP return ( -
-
-

Dobradinhas

-

- Deputados estaduais com quem a campanha dobra — vincule lideranças e municípios direto - na lista. -

-
+
-
+
- - - - - } - /> -
- -
- -
-
- {/* + + + + + + } + /> + } + /> + {/* 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). */} - - {children} - - - - {biometricEnrollment ? : null} - - - - + + {children} + + + + {biometricEnrollment ? ( + + ) : null} +
+
+
+
+
) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index e72c2123..030d59f0 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -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 }> } diff --git a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx index 67a5a5ca..1d5ae5fd 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx @@ -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> } @@ -46,19 +49,12 @@ export default async function NewLeadershipPage({ searchParams }: NewLeadershipP return ( -
- -

Nova liderança

-

- O contato é reaproveitado pelo celular quando já existe. Registre o consentimento antes de - inserir dados reais. -

-
+ > } @@ -381,20 +384,14 @@ export default async function LeadershipsPage({ searchParams }: LeadershipsPageP return ( -
-
-

Lideranças

-

- Uma ficha por pessoa — cada liderança pode atuar em vários municípios e organizações. -

-
+
-
+ diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx index ba980fe2..16513cef 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx @@ -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 ( -
- -

Editar {view.name}

-

- 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. -

-
+ + {user.role === 'coordinator' ? ( searchParams: Promise diff --git a/src/app/(campaign)/campanha/(app)/municipios/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/page.tsx index c7a51607..fd8cbcbd 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/page.tsx @@ -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> } @@ -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 ( -
-

Municípios

-

- Os 435 municípios da campanha: um por município da Bahia — em Salvador, uma zona eleitoral - cada. -

- {getCampaignScopeLabel(user.role, scopeTotal)} -
- {main} {listVisitLabel ? ( } diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx index c8dc3eb3..c0413e66 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -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 ( -
- -

Nova organização

-

- Cadastre sindicatos, associações, movimentos e afins para vincular lideranças e - atividades. -

-
+ > } @@ -74,21 +77,14 @@ export default async function OrganizationsPage({ searchParams }: OrganizationsP return ( -
-
-

Organizações

-

- Sindicatos, associações e movimentos que apoiam a campanha — com suas lideranças e - atividades. -

-
+
-
+ diff --git a/src/app/(campaign)/campanha/(app)/quadro/page.tsx b/src/app/(campaign)/campanha/(app)/quadro/page.tsx index 108562b7..06774cd4 100644 --- a/src/app/(campaign)/campanha/(app)/quadro/page.tsx +++ b/src/app/(campaign)/campanha/(app)/quadro/page.tsx @@ -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 ( > } @@ -44,15 +47,6 @@ export default async function TerritoriesPage({ searchParams }: TerritoriesPageP return ( -
-

- Territórios de Identidade -

-

- 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. -

-
diff --git a/src/app/(campaign)/layout.tsx b/src/app/(campaign)/layout.tsx index 1acd6028..9db82c9b 100644 --- a/src/app/(campaign)/layout.tsx +++ b/src/app/(campaign)/layout.tsx @@ -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, diff --git a/src/components/campaign/auth/CampaignProfileSettings.tsx b/src/components/campaign/auth/CampaignProfileSettings.tsx index ea79c3e9..bfddac4d 100644 --- a/src/components/campaign/auth/CampaignProfileSettings.tsx +++ b/src/components/campaign/auth/CampaignProfileSettings.tsx @@ -64,13 +64,6 @@ export const CampaignProfileSettings = ({ return (
-
-

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 }) => ( -

-

Olá, {userName}

-

- {view.role === 'advisor' - ? 'Quadro dos municípios sob sua assessoria.' - : 'Quadro geral da campanha por município.'} -

-
- {mapSlot}
( -
-

Olá, {userName}

-

- Cadastre apoiadores pelo celular. Só você vê os contatos que criou aqui. -

-
- {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..e8df3ba9 --- /dev/null +++ b/src/components/campaign/shell/CampaignDesktopHeader.tsx @@ -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 }) => ( +

+ + + {notificationBell ?
{notificationBell}
: null} +
+) diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx index 9f3b37c7..daa02b89 100644 --- a/src/components/campaign/shell/CampaignMobileTopBar.tsx +++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx @@ -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?: ) : ( )} -
- 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..5a372ae9 --- /dev/null +++ b/src/components/campaign/shell/CampaignPageChromeDisplay.tsx @@ -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 +} diff --git a/src/components/campaign/shell/CampaignPageChromeText.tsx b/src/components/campaign/shell/CampaignPageChromeText.tsx new file mode 100644 index 00000000..24355d09 --- /dev/null +++ b/src/components/campaign/shell/CampaignPageChromeText.tsx @@ -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 ( +

+ {chrome.title} + {chrome.subtitle ? ( + {chrome.subtitle} + ) : null} +

+ ) + } + + return ( +
+ {chrome.title} + {chrome.subtitle ? ( + {chrome.subtitle} + ) : null} +
+ ) +} diff --git a/src/components/campaign/shell/CampaignSidebar.tsx b/src/components/campaign/shell/CampaignSidebar.tsx index 2724d7e4..d38c2e8f 100644 --- a/src/components/campaign/shell/CampaignSidebar.tsx +++ b/src/components/campaign/shell/CampaignSidebar.tsx @@ -7,9 +7,7 @@ import { useState, type FormEvent, type ReactNode } from 'react' import { LogOutIcon } from 'lucide-react' import { logoutCampaign } from '@/app/(campaign)/campanha/actions/auth' -import { CampaignScopeBadge } from '@/components/campaign/shared/CampaignScopeBadge' import { CampaignUserAvatar } from '@/components/campaign/shared/CampaignUserAvatar' -import { CampaignLogo } from '@/components/campaign/shell/campaign-logo' import { MunicipalityNavSavedFilters } from '@/components/campaign/shell/MunicipalityNavSavedFilters' import { getCampaignNav, @@ -25,7 +23,6 @@ import { SidebarFooter, SidebarGroup, SidebarGroupContent, - SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, @@ -35,7 +32,6 @@ import { Spinner } from '@/components/ui/Spinner' import { clearLastActedMunicipality } from '@/lib/campaignLastActedMunicipality' import { clearCampaignPwaCaches } from '@/utilities/campaignPwaClient' import type { CampaignUserShellView } from '@/utilities/campaignUserProfile' -import { campaignRoleLabels } from '@/utilities/campaignUserProfile' import { clearMunicipalitySavedFilters } from '@/utilities/municipality/municipalitySavedFilters' import { clearRecentVisits } from '@/utilities/recentVisits' @@ -88,20 +84,8 @@ export const CampaignSidebar = ({ user }: { user: CampaignSidebarUser }) => { return ( - - - - - - - - {campaignRoleLabels[user.role]} - {getCampaignNav(user.role).map((item) => ( diff --git a/src/components/ui/Sidebar.tsx b/src/components/ui/Sidebar.tsx index d99d82dd..b0e95f08 100644 --- a/src/components/ui/Sidebar.tsx +++ b/src/components/ui/Sidebar.tsx @@ -282,15 +282,6 @@ const SidebarInset = ({ className, ...props }: React.ComponentProps<'main'>) => /> ) -const SidebarHeader = ({ className, ...props }: React.ComponentProps<'div'>) => ( -
-) - const SidebarFooter = ({ className, ...props }: React.ComponentProps<'div'>) => (
{ + 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/src/utilities/municipality/municipalityLabels.ts b/src/utilities/municipality/municipalityLabels.ts index a4f5195a..92be249d 100644 --- a/src/utilities/municipality/municipalityLabels.ts +++ b/src/utilities/municipality/municipalityLabels.ts @@ -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'] 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..1a5e0871 100644 --- a/tests/e2e/campaignLeaderships.e2e.spec.ts +++ b/tests/e2e/campaignLeaderships.e2e.spec.ts @@ -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. diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 972d1611..1e74fece 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), @@ -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. @@ -427,8 +428,6 @@ test.describe('Municípios — FAB ações rápidas mobile (B126)', () => { await page.goto( `${campaign.baseURL}/campanha/municipios?q=${encodeURIComponent(municipality.name)}`, ) - await expect(page.getByRole('heading', { name: 'Municípios' })).toBeVisible() - const fab = page.getByRole('button', { name: 'Ações rápidas' }) await expect(fab).toBeVisible() await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0) @@ -479,7 +478,7 @@ test.describe('Municípios — FAB overlay polish (B126)', () => { await campaign.login(page, coordinator.email!, coordinator.password) await page.goto(`${campaign.baseURL}/campanha/municipios`) - await expect(page.getByRole('heading', { name: 'Municípios' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Ações rápidas' })).toBeVisible() await page.getByRole('button', { name: 'Ações rápidas' }).click() const overlay = page.locator('#CampaignQuickActionsOverlay') 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..e8cd3b7a 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"]').filter({ visible: true }).getByText(title) + export { expect } diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx index 56d61633..b4822959 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(), @@ -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( - - - - - - - + + + + + + + + + , ) +} 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') + }) +}) diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx index 0f6524bd..54a0603f 100644 --- a/tests/unit/campaignWizardShell.unit.spec.tsx +++ b/tests/unit/campaignWizardShell.unit.spec.tsx @@ -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) => render( - - - - + + + + + + , ) @@ -104,35 +108,39 @@ describe('CampaignWizardShell', () => { it('skips title focus when contentFocus is none', () => { const { rerender } = render( - - - - - - + + + + + + + + , ) rerender( - - - - - - + + + + + + + + , ) @@ -144,33 +152,37 @@ describe('CampaignWizardShell', () => { it('moves focus to the step title when stepTitle changes', () => { const { rerender } = render( - - - - Corpo - - + + + + + Corpo + + + , ) rerender( - - - - Corpo - - + + + + + Corpo + + + , ) diff --git a/tests/unit/wizardNavigationPending.unit.spec.tsx b/tests/unit/wizardNavigationPending.unit.spec.tsx index 277dc4ae..40d44027 100644 --- a/tests/unit/wizardNavigationPending.unit.spec.tsx +++ b/tests/unit/wizardNavigationPending.unit.spec.tsx @@ -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( - - - - - Corpo do passo - - - + + + + + + Corpo do passo + + + + , )