From ccfbf592a8c7acd6255ae2465d119e2af0490459 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 18:08:47 +0000 Subject: [PATCH] =?UTF-8?q?B123=20=E2=80=94=20orienta=C3=A7=C3=A3o=20na=20?= =?UTF-8?q?shell=20+=20aba=20sem=20t=C3=ADtulos=20de=20se=C3=A7=C3=A3o=20n?= =?UTF-8?q?o=20corpo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move page title/subtitle to CampaignMobileTopBar, CampaignDesktopHeader, and tab metadata (Solla - Campanha - %s). Strip section headers and scope chips from list/create routes; detail pages keep entity h1. Co-authored-by: Francisco Solla --- ...orientacao-shell-sem-titulos-secao-impl.md | 72 +++++ .../campanha/(app)/apoiadores/[id]/page.tsx | 6 +- .../(app)/apoiadores/importar/page.tsx | 17 +- .../campanha/(app)/apoiadores/novo/page.tsx | 18 +- .../campanha/(app)/apoiadores/page.tsx | 49 ++- .../campanha/(app)/assessores/[id]/page.tsx | 3 + .../campanha/(app)/assessores/page.tsx | 11 +- .../(app)/atividades/[slug]/editar/page.tsx | 32 +- .../campanha/(app)/atividades/[slug]/page.tsx | 3 + .../campanha/(app)/atividades/giros/page.tsx | 19 +- .../campanha/(app)/atividades/nova/page.tsx | 10 +- .../campanha/(app)/atividades/page.tsx | 43 ++- .../campanha/(app)/conceitos/page.tsx | 17 +- .../campanha/(app)/contatos/page.tsx | 4 +- .../campanha/(app)/demandas/[slug]/page.tsx | 3 + .../campanha/(app)/demandas/nova/page.tsx | 22 +- .../campanha/(app)/demandas/page.tsx | 19 +- .../(app)/dobradinhas/[slug]/page.tsx | 3 + .../campanha/(app)/dobradinhas/nova/page.tsx | 22 +- .../campanha/(app)/dobradinhas/page.tsx | 14 +- src/app/(campaign)/campanha/(app)/layout.tsx | 12 +- .../campanha/(app)/liderancas/[id]/page.tsx | 3 + .../campanha/(app)/liderancas/nova/page.tsx | 22 +- .../campanha/(app)/liderancas/page.tsx | 13 +- .../(app)/municipios/[slug]/editar/page.tsx | 41 ++- .../campanha/(app)/municipios/[slug]/page.tsx | 3 + .../campanha/(app)/municipios/page.tsx | 15 +- .../(app)/organizacoes/[slug]/page.tsx | 3 + .../campanha/(app)/organizacoes/nova/page.tsx | 22 +- .../campanha/(app)/organizacoes/page.tsx | 14 +- src/app/(campaign)/campanha/(app)/page.tsx | 3 + .../(campaign)/campanha/(app)/perfil/page.tsx | 10 +- .../(campaign)/campanha/(app)/quadro/page.tsx | 4 +- .../campanha/(app)/territorios/page.tsx | 12 +- src/app/(campaign)/layout.tsx | 5 +- .../campaign/auth/CampaignProfileSettings.tsx | 7 - .../campaign/dashboard/CampaignDashboard.tsx | 11 - .../leadership/LeaderContactsPanel.tsx | 9 - .../campaign/shell/CampaignDesktopHeader.tsx | 16 + .../campaign/shell/CampaignMobileTopBar.tsx | 6 +- .../shell/CampaignPageChromeContext.tsx | 71 ++++ .../shell/CampaignPageChromeDisplay.tsx | 32 ++ .../campaign/shell/CampaignPageChromeText.tsx | 38 +++ src/lib/campaignPageChrome.ts | 305 ++++++++++++++++++ tests/e2e/campaignConcepts.e2e.spec.ts | 10 +- tests/e2e/campaignLeaderships.e2e.spec.ts | 6 +- tests/e2e/campaignMunicipalities.e2e.spec.ts | 9 +- tests/e2e/campaignTerritories.e2e.spec.ts | 6 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 3 + tests/unit/campaignMobileTopBar.unit.spec.tsx | 100 +++--- tests/unit/campaignPageChrome.unit.spec.ts | 42 +++ 51 files changed, 875 insertions(+), 365 deletions(-) create mode 100644 docs/plans/orientacao-shell-sem-titulos-secao-impl.md create mode 100644 src/components/campaign/shell/CampaignDesktopHeader.tsx create mode 100644 src/components/campaign/shell/CampaignPageChromeContext.tsx create mode 100644 src/components/campaign/shell/CampaignPageChromeDisplay.tsx create mode 100644 src/components/campaign/shell/CampaignPageChromeText.tsx create mode 100644 src/lib/campaignPageChrome.ts create mode 100644 tests/unit/campaignPageChrome.unit.spec.ts 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..28893abb --- /dev/null +++ b/docs/plans/orientacao-shell-sem-titulos-secao-impl.md @@ -0,0 +1,72 @@ +# 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 diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 24b7587b..7cd83086 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' @@ -14,7 +13,7 @@ import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' -import { campaignRoleLabels } from '@/utilities/campaignUserProfile' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' 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..d68f241e 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 { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { Button } from '@/components/ui/button' 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..0584e894 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 { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { Button } from '@/components/ui/button' 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..03f4f835 100644 --- a/src/app/(campaign)/campanha/(app)/contatos/page.tsx +++ b/src/app/(campaign)/campanha/(app)/contatos/page.tsx @@ -6,9 +6,12 @@ import { LeaderContactsPanel } from '@/components/campaign/leadership/LeaderCont import { isCampaignLeader } from '@/utilities/campaignAccess' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadLeaderContactsPageData } from '@/utilities/leaderContactsPageData' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' 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..f88a2cd3 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/nova/page.tsx @@ -6,6 +6,7 @@ import { getPayload } from 'payload' import { searchDemandActivityOptions } from '@/app/(campaign)/campanha/(app)/demandas/activitySearchActions' import { DemandForm } from '@/components/campaign/demand/DemandForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { Button } from '@/components/ui/button' import { loadActivityRelationOptionById } from '@/utilities/activityRelationOptions' import { firstValue, strictDecimalInteger } from '@/utilities/campaignListUrl' @@ -13,6 +14,8 @@ 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,7 +103,7 @@ export default async function DemandsPage({ searchParams }: DemandsPageProps) { ]) const state = parseDemandListParams(rawSearchParams) - const { rows, totalDocs, totalPages, openCount } = await loadDemandListPageData( + const { rows, totalDocs, totalPages } = await loadDemandListPageData( payload, user, state, @@ -112,24 +115,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..e1cfc60a 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/nova/page.tsx @@ -2,29 +2,25 @@ import { ArrowLeftIcon } from 'lucide-react' import Link from 'next/link' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Button } from '@/components/ui/button' 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..d93368aa 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/page.tsx @@ -39,6 +39,7 @@ import { cn } from '@/lib/utils' import { getAdvisorMunicipalityIds } from '@/utilities/campaignAccess' import { readCampaignColumnVisibility } from '@/utilities/campaignColumnVisibilityCookie' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { loadLeadershipOptions } from '@/utilities/campaignRelationOptions' import { loadMunicipalityPortfolioIndex } from '@/utilities/municipality/municipalityPortfolioIndex' import { @@ -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. -

-
+
-
+
+ @@ -82,12 +84,7 @@ export default async function CampaignAppLayout({ children }: { children: React. } /> -
- -
- -
-
+ {/* Provider must wrap CampaignAppScrollChrome, not only page children: the mobile quick-actions drawer (B91/B100) mounts as a sibling of @@ -105,6 +102,7 @@ export default async function CampaignAppLayout({ children }: { children: React.
+
) diff --git a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx index e72c2123..dcae8cb7 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/[id]/page.tsx @@ -14,6 +14,7 @@ import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { resolvedPortfolioEntriesById } from '@/lib/municipalityPortfolio' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { loadMunicipalityOptions, loadOrganizationOptions, @@ -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..b981010c 100644 --- a/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/liderancas/nova/page.tsx @@ -5,6 +5,7 @@ import { getPayload } from 'payload' import { LeadershipForm } from '@/components/campaign/leadership/LeadershipForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { Button } from '@/components/ui/button' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { @@ -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..1e22e72b 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/editar/page.tsx @@ -7,7 +7,9 @@ import { getPayload } from 'payload' import { MunicipalityAdvisorsForm } from '@/components/campaign/municipality/MunicipalityAdvisorsForm' import { MunicipalityStrategyForm } from '@/components/campaign/municipality/MunicipalityStrategyForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' 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,23 @@ 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 +70,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..4ec393ad 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/nova/page.tsx @@ -5,11 +5,14 @@ import { getPayload } from 'payload' import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { Button } from '@/components/ui/button' 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..dd5cc538 100644 --- a/src/app/(campaign)/campanha/(app)/quadro/page.tsx +++ b/src/app/(campaign)/campanha/(app)/quadro/page.tsx @@ -12,12 +12,15 @@ import { import type { CampaignUser } from '@/payload-types' import { getCampaignDashboardData } from '@/utilities/campaignDashboardData' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' +import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' import { loadFederalCandidateOptions } from '@/utilities/electionCandidateOptions' import { loadMunicipalityMapBundle } from '@/utilities/municipality/municipalityMapData' import { loadMunicipalitySuggestions } from '@/utilities/municipality/municipalityTriggers' 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..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') + }) +})