From 5696aef9edf3cdc4b5ebe358ca32300e5eb61b73 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:13:59 +0000 Subject: [PATCH 1/5] =?UTF-8?q?B145=20=E2=80=94=20move=20entity=20identity?= =?UTF-8?q?=20to=20shell=20header=20on=20detail=20pages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detail routes return null from campaignPageChrome catalog (override per page) - SetCampaignPageChrome + generateMetadata on 8 entity detail routes - Remove body hero (h1, Voltar links, duplicate identity chips) - Keep operational badges/chips in page body - Update zone map e2e to assert shell chrome title slot Co-authored-by: Francisco Solla --- .../detalhe-entidade-titulo-no-header-impl.md | 77 ++++++++++++++++ .../campanha/(app)/apoiadores/[id]/page.tsx | 40 ++++---- .../campanha/(app)/assessores/[id]/page.tsx | 38 ++++---- .../campanha/(app)/atividades/[slug]/page.tsx | 61 ++++++++---- .../campanha/(app)/demandas/[slug]/page.tsx | 92 ++++++++++--------- .../(app)/dobradinhas/[slug]/page.tsx | 48 ++++++---- .../campanha/(app)/liderancas/[id]/page.tsx | 59 ++++++------ .../campanha/(app)/municipios/[slug]/page.tsx | 46 +++++----- .../(app)/organizacoes/[slug]/page.tsx | 56 ++++++----- src/lib/campaignPageChrome.ts | 20 ++-- tests/e2e/campaignZoneMap.e2e.spec.ts | 6 +- tests/unit/campaignPageChrome.unit.spec.ts | 11 ++- 12 files changed, 347 insertions(+), 207 deletions(-) create mode 100644 docs/plans/detalhe-entidade-titulo-no-header-impl.md diff --git a/docs/plans/detalhe-entidade-titulo-no-header-impl.md b/docs/plans/detalhe-entidade-titulo-no-header-impl.md new file mode 100644 index 00000000..90fd74cc --- /dev/null +++ b/docs/plans/detalhe-entidade-titulo-no-header-impl.md @@ -0,0 +1,77 @@ +# Impl: Detalhe de entidade — título no header (sem hero espaçoso) + +Status: aprovado +Atualizado em: 2026-08-02 +Issue: #315 +Intenção: docs/plans/detalhe-entidade-titulo-no-header.md +Appetite restante: ~0,5 dia eng (8 rotas de detalhe + catálogo + e2e) + +## Leitura da intenção + +- **Outcome:** Em cada detalhe de entidade em `/campanha`, a identidade do registro (título + subtítulo) vive no header da shell; o corpo começa direto em abas/seções/chips operacionais, sem hero nem “Voltar para…”. +- **O que NÃO negociar:** listas mantêm header de seção (B123/B133); edit routes inalteradas; chips operacionais no corpo; leader lockdown; aba do browser alinhada à entidade. +- **O que reavaliar:** hipótese de tocar só `campaignPageChrome` — insuficiente: títulos são dinâmicos por registro; precedente de `SetCampaignPageChrome` + `generateMetadata` nas rotas `/editar` é o dono. + +## Abordagem recomendada + +```mermaid +flowchart LR + RSC[Detail page RSC] --> MD[generateMetadata] + RSC --> SC[SetCampaignPageChrome] + SC --> Shell[CampaignPageChromeDisplay] + RSC --> Body[Corpo sem hero] +``` + +**Opções consideradas:** +- A) Estender `resolveCampaignPageChrome` com dados de entidade (impossível — catálogo é estático/client-safe) +- B) `SetCampaignPageChrome` + `generateMetadata` por rota (precedente edit routes) +- C) Novo provider server-side de chrome + +**Recomendação:** B — reusa `SetCampaignPageChrome` e `campaignPageMetadata` já usados em `/editar` e `MunicipalityListPageChrome`; path rules de detalhe passam a `null` (como edit) para não flashar título de seção antes do override. + +**Rejeitadas:** C (twin desnecessário); A (dados dinâmicos não cabem no catálogo). + +### Componentes / mudanças + +- **`campaignPageChrome.ts`:** regras de detalhe retornam `null`; teste unitário atualizado. +- **8 rotas de detalhe** (`municipios/[slug]`, `liderancas/[id]`, `atividades/[slug]`, `demandas/[slug]`, `dobradinhas/[slug]`, `organizacoes/[slug]`, `apoiadores/[id]`, `assessores/[id]`): `generateMetadata` + `SetCampaignPageChrome`; remover hero (`h1`, “Voltar…”, chips de identidade duplicados, linha Assessoria no município). +- **`campaignZoneMap.e2e.spec.ts`:** assert no chrome da shell (`data-slot=campaign-page-chrome-title`) em vez de `h1` no corpo. +- **Migration:** sem migration +- **Access / Consent:** inalterado + +### Mapeamento chrome por rota + +| Rota | title | subtitle | +| ---- | ----- | -------- | +| municipios/[slug] | view.name | formatMunicipalityGeographyLabel(view) | +| liderancas/[id] | Liderança | leadership.name | +| atividades/[slug] | view.title | view.locationLabel ou municipality.name | +| demandas/[slug] | demand.title | `${kind} · ${municipalityName}` | +| dobradinhas/[slug] | stateDeputy.name | party (opcional) | +| organizacoes/[slug] | organization.name | organizationKindLabels[kind] | +| apoiadores/[id] | Apoiador | supporter.name | +| assessores/[id] | Assessor | advisor.name | + +## Fases verificáveis + +1. **Catálogo + testes** — path rules `null` para detalhes; unit spec +2. **UI — 8 páginas** — chrome override + remoção de hero; metadata dinâmica +3. **Gates** — `pnpm gate:fast`; push via `pnpm push` + +## Rabbit holes / Não escopo (engenharia) + +- Novo bloco UI para Assessoria no município +- Wizard, giros, perfil, contatos, rotas `/editar`, listas +- Refatorar `CampaignPageChromeText` (layout já suporta subtitle) + +## Riscos e mitigação + +- **Flash de título de seção:** path rule `null` + `useLayoutEffect` do override (mesmo que edit routes) +- **E2E que asserta `h1`:** atualizar para chrome slot +- **Atividade sem locationLabel:** fallback para `municipality?.name` + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto +- [x] Invariantes AGENTS/engineering-standards +- [x] Testes de domínio previstos (unit chrome + e2e zone map) diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index 1d8b97f2..b248fbc6 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -1,5 +1,5 @@ import config from '@payload-config' -import { ArrowLeftIcon, ShieldCheckIcon } from 'lucide-react' +import { ShieldCheckIcon } from 'lucide-react' import Link from 'next/link' import { notFound, redirect } from 'next/navigation' import { getPayload } from 'payload' @@ -9,10 +9,10 @@ import { ConsentText } from '@/components/campaign/shared/ConsentText' import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton' import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit' import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntentionControl' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' 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 { campaignPageMetadata } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadSupporterDetailConsentData, @@ -25,7 +25,21 @@ import { } from '@/utilities/supporter/supporterUi' import { parseSupporterId } from '@/utilities/supporter/supporterViewModels' -export const metadata = campaignPageMetadataFromCatalog('apoiadores') +export async function generateMetadata({ params }: SupporterDetailPageProps) { + const { id: rawId } = await params + const supporterId = parseSupporterId(rawId) + if (supporterId === null) return campaignPageMetadata({ title: 'Apoiador' }) + + const [user, payload] = await Promise.all([requireCampaignPageActor(), getPayload({ config })]) + if (!canAccessSupporterArea(user.role)) return campaignPageMetadata({ title: 'Apoiador' }) + + try { + const supporter = await loadSupporterDetailPageData(payload, user, supporterId) + return campaignPageMetadata({ title: 'Apoiador', subtitle: supporter.name }) + } catch { + return campaignPageMetadata({ title: 'Apoiador' }) + } +} type SupporterDetailPageProps = { params: Promise<{ id: string }> @@ -65,19 +79,11 @@ export default async function SupporterDetailPage({ params }: SupporterDetailPag return (
-
- -

{supporter.name}

-

- {supporter.city ?? 'Cidade não informada'} - {supporter.territory ? ` · ${supporter.territory}` : ''} -

-
+ +

+ {supporter.city ?? 'Cidade não informada'} + {supporter.territory ? ` · ${supporter.territory}` : ''} +

diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index 89d4b78b..e8e7dad9 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -1,5 +1,5 @@ import config from '@payload-config' -import { ArrowLeftIcon, MapPinIcon } from 'lucide-react' +import { MapPinIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' @@ -7,16 +7,28 @@ import { getPayload } from 'payload' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBrazilianPhoneInput } from '@/lib/phone' import { isPlanilhaPlaceholderEmail } from '@/lib/schemas/advisor' import { loadAdvisorDetail } from '@/utilities/advisorData' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { sendAdvisorPasswordResetFormAction, updateAdvisorProfileFormAction } from '../formActions' -export const metadata = campaignPageMetadataFromCatalog('assessores') +export async function generateMetadata({ params }: AdvisorDetailPageProps) { + const { id: rawId } = await params + const advisorId = Number(rawId) + if (!Number.isInteger(advisorId) || advisorId <= 0) { + return campaignPageMetadata({ title: 'Assessor' }) + } + + const payload = await getPayload({ config }) + const advisor = await loadAdvisorDetail(payload, advisorId) + if (!advisor) return campaignPageMetadata({ title: 'Assessor' }) + + return campaignPageMetadata({ title: 'Assessor', subtitle: advisor.name }) +} type AdvisorDetailPageProps = { params: Promise<{ id: string }> @@ -39,19 +51,11 @@ export default async function AdvisorDetailPage({ params }: AdvisorDetailPagePro return ( -
- -

{advisor.name}

-

- E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo - edição da lista. -

-
+ +

+ E-mail e celular salvam sozinhos ao digitar. A carteira de municípios é editada no modo + edição da lista. +

diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index e3abed34..125afc60 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -14,10 +14,11 @@ import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTa import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed' import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm' import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' 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 { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBahiaDateTimeLabel } from '@/lib/campaignTime' import { activityKindLabels } from '@/lib/schemas/activity' import { getActivityDetailPageData } from '@/utilities/activityDetailPageData' @@ -35,7 +36,27 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { ActivityOverviewTab } from './ActivityOverviewTab' -export const metadata = campaignPageMetadataFromCatalog('atividades') +export async function generateMetadata({ params }: ActivityDetailPageProps) { + const { slug } = await params + if (!slug) return campaignPageMetadata({ title: 'Atividade' }) + + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + try { + const activeTab = 'overview' as const + const context = await resolveAccessibleActivityContext(payload, user, slug, activeTab) + const view = await getActivityDetailPageData(payload, user, context, activeTab, 1) + const subtitle = view.locationLabel || view.municipality?.name + return campaignPageMetadata( + subtitle ? { title: view.title, subtitle } : { title: view.title }, + ) + } catch { + return campaignPageMetadata({ title: 'Atividade' }) + } +} type ActivityDetailPageProps = { params: Promise<{ slug: string }> @@ -73,30 +94,31 @@ export default async function ActivityDetailPage({ const isStaff = isCampaignStaff(user) const canManageLifecycle = isStaff && view.status !== 'realizado' && view.status !== 'cancelado' + const chromeSubtitle = view.locationLabel || view.municipality?.name return (
+ -
-
-
-
-

{view.title}

- - {view.deputyPresent ? Deputado presente : null} -
-
- {activityKindLabels[view.kind]} - - {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} - - {view.locationLabel ? · {view.locationLabel} : null} -
-
-
+
+
+ + {view.deputyPresent ? Deputado presente : null} + {activityKindLabels[view.kind]} + + {view.startAt ? formatBahiaDateTimeLabel(view.startAt) : 'Data a definir'} + +
+
{isStaff ? (
-
diff --git a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx index f95970c6..c7ad5900 100644 --- a/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/demandas/[slug]/page.tsx @@ -1,5 +1,5 @@ import config from '@payload-config' -import { ArrowLeftIcon, PaperclipIcon } from 'lucide-react' +import { PaperclipIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' @@ -7,9 +7,9 @@ import { getPayload } from 'payload' import { DemandWorkflowCard } from '@/components/campaign/demand/DemandWorkflowCard' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { CampaignQuickActionContextSync } from '@/components/campaign/shell/CampaignQuickActionContextSync' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { campaignDemandKindLabels, campaignDemandStatusLabels, @@ -24,7 +24,21 @@ import { transitionDemandFormAction, } from './formActions' -export const metadata = campaignPageMetadataFromCatalog('demandas') +export async function generateMetadata({ params }: DemandDetailPageProps) { + const { slug } = await params + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const demand = await loadDemandDetail(payload, user, slug) + if (!demand) return campaignPageMetadata({ title: 'Demanda' }) + + return campaignPageMetadata({ + title: demand.title, + subtitle: `${campaignDemandKindLabels[demand.kind]} · ${demand.municipalityName}`, + }) +} type DemandDetailPageProps = { params: Promise<{ slug: string }> @@ -63,48 +77,44 @@ export default async function DemandDetailPage({ params }: DemandDetailPageProps return ( + 0 ? demand.municipalityId : undefined} demandSlug={demand.slug} /> -
- -
-

{demand.title}

- - {campaignDemandStatusLabels[demand.status]} - -
-

- {campaignDemandKindLabels[demand.kind]} ·{' '} - - {demand.municipalityName} - - {demand.activity ? ( - <> - {' '} - · Atividade:{' '} - - {demand.activity.title} - - - ) : null} - {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} - {dateTimeFormatter.format(new Date(demand.createdAt))} -

-
+
+ + {campaignDemandStatusLabels[demand.status]} + +
+

+ + {demand.municipalityName} + + {demand.activity ? ( + <> + {' '} + · Atividade:{' '} + + {demand.activity.title} + + + ) : null} + {demand.requesterName ? ` · Solicitada por ${demand.requesterName}` : ''} · Aberta em{' '} + {dateTimeFormatter.format(new Date(demand.createdAt))} +

{demand.description ? (
diff --git a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx index 5f1a5cd5..537d9f09 100644 --- a/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/dobradinhas/[slug]/page.tsx @@ -1,19 +1,34 @@ import config from '@payload-config' -import { ArrowLeftIcon, HandshakeIcon, MapPinIcon } from 'lucide-react' +import { HandshakeIcon, MapPinIcon } from 'lucide-react' import Link from 'next/link' import { notFound } from 'next/navigation' import { getPayload } from 'payload' import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { StateDeputyForm } from '@/components/campaign/stateDeputy/StateDeputyForm' import { Badge } from '@/components/ui/Badge' -import { Button } from '@/components/ui/button' -import { campaignPageMetadataFromCatalog } from '@/lib/campaignPageChrome' +import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { loadStateDeputyDetail } from '@/utilities/stateDeputyData' import { updateStateDeputyFormAction } from './formActions' -export const metadata = campaignPageMetadataFromCatalog('dobradinhas') +export async function generateMetadata({ params }: StateDeputyDetailPageProps) { + const { slug } = await params + const [user, payload] = await Promise.all([ + requireCampaignPageActor({ gate: 'staff' }), + getPayload({ config }), + ]) + + const stateDeputy = await loadStateDeputyDetail(payload, user, slug) + if (!stateDeputy) return campaignPageMetadata({ title: 'Dobradinha' }) + + return campaignPageMetadata( + stateDeputy.party + ? { title: stateDeputy.name, subtitle: stateDeputy.party } + : { title: stateDeputy.name }, + ) +} type StateDeputyDetailPageProps = { params: Promise<{ slug: string }> @@ -31,21 +46,16 @@ export default async function StateDeputyDetailPage({ params }: StateDeputyDetai return ( -
- -
-

{stateDeputy.name}

- {stateDeputy.party ? {stateDeputy.party} : null} -
- {stateDeputy.notes ? ( -

{stateDeputy.notes}

- ) : null} -
+ + {stateDeputy.notes ? ( +

{stateDeputy.notes}

+ ) : null}
@@ -58,34 +69,26 @@ export default async function LeadershipDetailPage({ params }: LeadershipDetailP return ( + -
- -
-

{leadership.name}

- {leadership.supportStatus ? ( - - ) : null} - - {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} - -
-

- {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} - {leadership.email ? ` · ${leadership.email}` : ''} -

- {leadership.stateDeputies.length > 0 ? ( - +
+ {leadership.supportStatus ? ( + ) : null} -
+ + {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} + +
+

+ {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} + {leadership.email ? ` · ${leadership.email}` : ''} +

+ {leadership.stateDeputies.length > 0 ? ( + + ) : null}
searchParams: Promise } -const dateFormatter = new Intl.DateTimeFormat('pt-BR') - export default async function MunicipalityDetailPage({ params, searchParams, @@ -68,22 +80,12 @@ export default async function MunicipalityDetailPage({ return ( - {/* print: the dossier tab carries its own capa; page chrome stays out. */} -
-
-

{view.name}

- {municipalityKindLabels[view.kind]} -
-

{formatMunicipalityGeographyLabel(view)}

-

- {advisorSummaries.length - ? `Assessoria: ${advisorSummaries.map((advisor) => advisor.name).join(', ')}` - : 'Sem assessor designado.'} - {view.lastUpdateAt - ? ` · Última atualização em ${dateFormatter.format(new Date(view.lastUpdateAt))}` - : ''} -

-
+ @@ -38,26 +52,20 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet return ( -
- -
-

{organization.name}

- {organizationKindLabels[organization.kind]} -
- {organization.municipalityNames.length ? ( -

- Atua em {organization.municipalityNames.join(', ')} -

- ) : null} - {organization.notes ? ( -

{organization.notes}

- ) : null} -
+ + {organization.municipalityNames.length ? ( +

+ Atua em {organization.municipalityNames.join(', ')} +

+ ) : null} + {organization.notes ? ( +

{organization.notes}

+ ) : null}
({ title }) - type PathRule = { match: (pathname: string) => boolean resolve: () => CampaignPageChrome | null @@ -128,12 +126,12 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/municipios\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title), + resolve: () => null, }, { // B147 parallel v2 — soft-dep B145 for entity name in chrome; section title for now. match: (pathname) => /^\/campanha\/municipio\/[^/]+\/v2$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.municipios.title), + resolve: () => ({ title: campaignPageChromeCatalog.municipios.title }), }, { match: (pathname) => pathname === '/campanha/territorios', @@ -145,7 +143,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/liderancas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.liderancas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/liderancas', @@ -157,7 +155,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/dobradinhas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.dobradinhas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/dobradinhas', @@ -177,7 +175,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/atividades\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.atividades.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/atividades', @@ -189,7 +187,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/demandas\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.demandas.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/demandas', @@ -205,7 +203,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/apoiadores\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.apoiadores.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/apoiadores', @@ -213,7 +211,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/assessores\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.assessores.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/assessores', @@ -225,7 +223,7 @@ const pathRules: PathRule[] = [ }, { match: (pathname) => /^\/campanha\/organizacoes\/[^/]+$/.test(pathname), - resolve: () => sectionOnly(campaignPageChromeCatalog.organizacoes.title), + resolve: () => null, }, { match: (pathname) => pathname === '/campanha/organizacoes', diff --git a/tests/e2e/campaignZoneMap.e2e.spec.ts b/tests/e2e/campaignZoneMap.e2e.spec.ts index fa70bd37..b0a07317 100644 --- a/tests/e2e/campaignZoneMap.e2e.spec.ts +++ b/tests/e2e/campaignZoneMap.e2e.spec.ts @@ -49,9 +49,7 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca await expect(readout).toContainText(/Salvador — ZE \d+/) await readout.getByRole('link', { name: 'Abrir município' }).click() - // The slug is the assertion: the detail page's own

reads the municipality - // ROW, which other specs rename, while the polygon's name comes from the - // static catalog. + // B145: entity identity lives in the shell header, not a body h1. await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/) - await expect(page.getByRole('heading', { level: 1 })).toBeVisible() + await expect(page.locator('[data-slot="campaign-page-chrome-title"]')).toBeVisible() }) diff --git a/tests/unit/campaignPageChrome.unit.spec.ts b/tests/unit/campaignPageChrome.unit.spec.ts index 93b257a4..ce4ef71f 100644 --- a/tests/unit/campaignPageChrome.unit.spec.ts +++ b/tests/unit/campaignPageChrome.unit.spec.ts @@ -21,10 +21,13 @@ describe('campaignPageChrome', () => { }) }) - it('resolves detail pages with section title only', () => { - expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toEqual({ - title: 'Municípios', - }) + it('returns null for entity detail pages (chrome set per route)', () => { + expect(resolveCampaignPageChrome('/campanha/municipios/cairu', 'coordinator')).toBeNull() + expect(resolveCampaignPageChrome('/campanha/liderancas/42', 'advisor')).toBeNull() + expect(resolveCampaignPageChrome('/campanha/atividades/foo', 'coordinator')).toBeNull() + }) + + it('keeps section title on parallel municipio v2 route (B147 soft-dep)', () => { expect(resolveCampaignPageChrome('/campanha/municipio/cairu/v2', 'coordinator')).toEqual({ title: 'Municípios', }) From 9bcbfaf96d7c3ab5c9a6ba41379e9f0cd816008a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:16:33 +0000 Subject: [PATCH 2/5] fix: normalize activity detail action row indentation (simplify) Co-authored-by: Francisco Solla --- .../detalhe-entidade-titulo-no-header-impl.md | 7 ++-- .../campanha/(app)/atividades/[slug]/page.tsx | 36 +++++++++---------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/docs/plans/detalhe-entidade-titulo-no-header-impl.md b/docs/plans/detalhe-entidade-titulo-no-header-impl.md index 90fd74cc..fb1f017f 100644 --- a/docs/plans/detalhe-entidade-titulo-no-header-impl.md +++ b/docs/plans/detalhe-entidade-titulo-no-header-impl.md @@ -70,8 +70,7 @@ flowchart LR - **E2E que asserta `h1`:** atualizar para chrome slot - **Atividade sem locationLabel:** fallback para `municipality?.name` -## Aceite de engenharia +## Débitos deferidos (capture-review-debts autônomo) -- [x] Aceite de produto da intenção ainda coberto -- [x] Invariantes AGENTS/engineering-standards -- [x] Testes de domínio previstos (unit chrome + e2e zone map) +- **Domain `*DetailPageChrome` builders** (score 4, defer_trigger): quando um 3º detalhe precisar de lógica de subtítulo compartilhada além do padrão SetCampaignPageChrome — extrair no módulo dono do view model, não HOF genérico. +- **`cache()` em loaders de detalhe** (score 3, defer_trigger): edit routes já duplicam fetch MD+página; otimizar loaders no dono quando houver evidência de custo em prod. diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index 125afc60..1908ebab 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -119,28 +119,28 @@ export default async function ActivityDetailPage({

- {isStaff ? ( - - ) : null} + {isStaff ? ( - {canManageLifecycle ? ( - <> - - - - ) : null} -
+ ) : null} + + {canManageLifecycle ? ( + <> + + + + ) : null}
+

From 33d2e5ba018193ba5ae46a317c9425d13bbcf3af Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:17:17 +0000 Subject: [PATCH 3/5] chore: format B145 detail pages --- .../detalhe-entidade-titulo-no-header-impl.md | 21 ++++++++++--------- .../campanha/(app)/apoiadores/[id]/page.tsx | 2 +- .../campanha/(app)/assessores/[id]/page.tsx | 2 +- .../campanha/(app)/atividades/[slug]/page.tsx | 10 +++------ .../campanha/(app)/demandas/[slug]/page.tsx | 2 +- .../(app)/dobradinhas/[slug]/page.tsx | 2 +- .../campanha/(app)/liderancas/[id]/page.tsx | 6 ++---- .../campanha/(app)/municipios/[slug]/page.tsx | 6 ++---- .../(app)/organizacoes/[slug]/page.tsx | 6 ++---- 9 files changed, 24 insertions(+), 33 deletions(-) diff --git a/docs/plans/detalhe-entidade-titulo-no-header-impl.md b/docs/plans/detalhe-entidade-titulo-no-header-impl.md index fb1f017f..a92d2771 100644 --- a/docs/plans/detalhe-entidade-titulo-no-header-impl.md +++ b/docs/plans/detalhe-entidade-titulo-no-header-impl.md @@ -23,6 +23,7 @@ flowchart LR ``` **Opções consideradas:** + - A) Estender `resolveCampaignPageChrome` com dados de entidade (impossível — catálogo é estático/client-safe) - B) `SetCampaignPageChrome` + `generateMetadata` por rota (precedente edit routes) - C) Novo provider server-side de chrome @@ -41,16 +42,16 @@ flowchart LR ### Mapeamento chrome por rota -| Rota | title | subtitle | -| ---- | ----- | -------- | -| municipios/[slug] | view.name | formatMunicipalityGeographyLabel(view) | -| liderancas/[id] | Liderança | leadership.name | -| atividades/[slug] | view.title | view.locationLabel ou municipality.name | -| demandas/[slug] | demand.title | `${kind} · ${municipalityName}` | -| dobradinhas/[slug] | stateDeputy.name | party (opcional) | -| organizacoes/[slug] | organization.name | organizationKindLabels[kind] | -| apoiadores/[id] | Apoiador | supporter.name | -| assessores/[id] | Assessor | advisor.name | +| Rota | title | subtitle | +| ------------------- | ----------------- | --------------------------------------- | +| municipios/[slug] | view.name | formatMunicipalityGeographyLabel(view) | +| liderancas/[id] | Liderança | leadership.name | +| atividades/[slug] | view.title | view.locationLabel ou municipality.name | +| demandas/[slug] | demand.title | `${kind} · ${municipalityName}` | +| dobradinhas/[slug] | stateDeputy.name | party (opcional) | +| organizacoes/[slug] | organization.name | organizationKindLabels[kind] | +| apoiadores/[id] | Apoiador | supporter.name | +| assessores/[id] | Assessor | advisor.name | ## Fases verificáveis diff --git a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx index b248fbc6..7b6f2bac 100644 --- a/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/apoiadores/[id]/page.tsx @@ -6,10 +6,10 @@ import { getPayload } from 'payload' import { setSupporterVoteIntentionFormAction } from '@/app/(campaign)/campanha/(app)/apoiadores/[id]/formActions' import { ConsentText } from '@/components/campaign/shared/ConsentText' +import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { RemoveSupporterDataButton } from '@/components/campaign/supporter/RemoveSupporterDataButton' import { SupporterShareKit } from '@/components/campaign/supporter/SupporterShareKit' import { VoteIntentionControl } from '@/components/campaign/supporter/VoteIntentionControl' -import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' import { Badge } from '@/components/ui/Badge' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { campaignPageMetadata } from '@/lib/campaignPageChrome' diff --git a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx index e8e7dad9..a11b028f 100644 --- a/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/assessores/[id]/page.tsx @@ -6,8 +6,8 @@ import { getPayload } from 'payload' import { AdvisorDebouncedTextCell } from '@/components/campaign/advisor/AdvisorDebouncedTextCell' import { AdvisorPasswordResetButton } from '@/components/campaign/advisor/AdvisorPasswordResetButton' -import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { formatBrazilianPhoneInput } from '@/lib/phone' diff --git a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx index 1908ebab..f347fe97 100644 --- a/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/atividades/[slug]/page.tsx @@ -13,8 +13,8 @@ import { ActivityTabNav } from '@/components/campaign/activity/ActivityTabNav' import { ActivityTaskChecklist } from '@/components/campaign/activity/ActivityTaskChecklist' import { ActivityUpdateFeed } from '@/components/campaign/activity/ActivityUpdateFeed' import { ActivityUpdateForm } from '@/components/campaign/activity/ActivityUpdateForm' -import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' +import { CampaignQuickActionContextBridge } from '@/components/campaign/shell/CampaignQuickActionContextBridge' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/button' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' @@ -50,9 +50,7 @@ export async function generateMetadata({ params }: ActivityDetailPageProps) { const context = await resolveAccessibleActivityContext(payload, user, slug, activeTab) const view = await getActivityDetailPageData(payload, user, context, activeTab, 1) const subtitle = view.locationLabel || view.municipality?.name - return campaignPageMetadata( - subtitle ? { title: view.title, subtitle } : { title: view.title }, - ) + return campaignPageMetadata(subtitle ? { title: view.title, subtitle } : { title: view.title }) } catch { return campaignPageMetadata({ title: 'Atividade' }) } @@ -100,9 +98,7 @@ export default async function ActivityDetailPage({
- {leadership.supportStatus ? ( - - ) : null} + {leadership.supportStatus ? : null} {leadership.hasAppAccess ? 'Com acesso ao app' : 'Sem acesso ao app'} diff --git a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx index 9ce08a6f..5c311fc5 100644 --- a/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/municipios/[slug]/page.tsx @@ -6,17 +6,15 @@ import { Suspense } from 'react' import { RecentVisitTracker } from '@/components/campaign/dashboard/RecentVisitTracker' import { MunicipalityTabNav } from '@/components/campaign/municipality/MunicipalityTabNav' -import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { requireCampaignPageActor } from '@/utilities/campaignPageActor' import { resolveMunicipalityDetailTab, type MunicipalityDetailSearchParams, } from '@/utilities/municipality/municipalityDetailTabUi' -import { - formatMunicipalityGeographyLabel, -} from '@/utilities/municipality/municipalityLabels' +import { formatMunicipalityGeographyLabel } from '@/utilities/municipality/municipalityLabels' import { getMunicipalityDetailViewModel, MunicipalityNotFoundError, diff --git a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx index 64bf020b..fa7d3ce8 100644 --- a/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx +++ b/src/app/(campaign)/campanha/(app)/organizacoes/[slug]/page.tsx @@ -6,8 +6,8 @@ import { getPayload } from 'payload' import { ActivityStatusBadge } from '@/components/campaign/activity/ActivityStatusBadge' import { OrganizationForm } from '@/components/campaign/organization/OrganizationForm' -import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { SetCampaignPageChrome } from '@/components/campaign/shell/CampaignPageChromeContext' +import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell' import { Badge } from '@/components/ui/Badge' import { campaignPageMetadata } from '@/lib/campaignPageChrome' import { organizationKindLabels } from '@/lib/schemas/organization' @@ -59,9 +59,7 @@ export default async function OrganizationDetailPage({ params }: OrganizationDet }} /> {organization.municipalityNames.length ? ( -

- Atua em {organization.municipalityNames.join(', ')} -

+

Atua em {organization.municipalityNames.join(', ')}

) : null} {organization.notes ? (

{organization.notes}

From c62fe67809255d2d52cb835d4e8bf78d86f786e3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:26:11 +0000 Subject: [PATCH 4/5] test(e2e): assert entity identity in shell chrome (B145) --- tests/e2e/campaignActivity.e2e.spec.ts | 4 ++-- tests/e2e/campaignMunicipalities.e2e.spec.ts | 6 +++--- tests/e2e/campaignZoneMap.e2e.spec.ts | 4 ++-- tests/e2e/fixtures/campaignE2EFixtures.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/e2e/campaignActivity.e2e.spec.ts b/tests/e2e/campaignActivity.e2e.spec.ts index c2ef7c8d..d7cef40d 100644 --- a/tests/e2e/campaignActivity.e2e.spec.ts +++ b/tests/e2e/campaignActivity.e2e.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' test.describe('Atividades — registro-fundação', () => { test.setTimeout(90_000) @@ -34,7 +34,7 @@ test.describe('Atividades — registro-fundação', () => { await page.getByRole('button', { name: 'Criar atividade' }).click() await expect(page).toHaveURL(/\/campanha\/atividades\/[^/?]+$/) - await expect(page.getByRole('heading', { name: activityTitle })).toBeVisible({ + await expect(campaignPageChrome(page, activityTitle)).toBeVisible({ timeout: 15_000, }) await expect(page.getByText('Obrigação política', { exact: true })).toBeVisible() diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index f4402c0f..2614000b 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -52,7 +52,7 @@ test.describe('Municípios — jornadas por papel', () => { ).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) - await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() + await expect(campaignPageChrome(page, municipality.name)).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}/editar`) await page.getByLabel(`${advisor.name} `, { exact: false }).check() @@ -360,7 +360,7 @@ test.describe('Municípios — jornadas por papel', () => { await page.getByLabel('Município').selectOption({ label: municipality.name }) await page.getByLabel('Detalhe a necessidade').fill('Precisamos para a caminhada de sábado.') await page.getByRole('button', { name: 'Abrir demanda' }).click() - await expect(page.getByRole('heading', { name: demandTitle })).toBeVisible() + await expect(campaignPageChrome(page, demandTitle)).toBeVisible() await expect(page.getByRole('button', { name: 'Aprovar' })).toBeVisible() await page.goto(`${campaign.baseURL}/campanha/demandas`) @@ -422,7 +422,7 @@ test.describe('Municípios — cards no celular (B42)', () => { // overlay receives it (as a user's finger would), and the card navigates. await card.getByText('Tendência', { exact: true }).click({ force: true }) await expect(page).toHaveURL(`${campaign.baseURL}/campanha/municipios/${municipality.slug}`) - await expect(page.getByRole('heading', { name: municipality.name })).toBeVisible() + await expect(campaignPageChrome(page, municipality.name)).toBeVisible() }) }) diff --git a/tests/e2e/campaignZoneMap.e2e.spec.ts b/tests/e2e/campaignZoneMap.e2e.spec.ts index b0a07317..2ec97438 100644 --- a/tests/e2e/campaignZoneMap.e2e.spec.ts +++ b/tests/e2e/campaignZoneMap.e2e.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from './fixtures/campaignE2EFixtures.js' +import { campaignPageChrome, expect, test } from './fixtures/campaignE2EFixtures.js' /** * B8 F2 — Salvador used to be one polygon carrying the whole city's votes, so @@ -51,5 +51,5 @@ test('the dashboard map paints Salvador zone by zone and opens one', async ({ ca await readout.getByRole('link', { name: 'Abrir município' }).click() // B145: entity identity lives in the shell header, not a body h1. await expect(page).toHaveURL(/\/campanha\/municipios\/salvador-ze-\d+$/) - await expect(page.locator('[data-slot="campaign-page-chrome-title"]')).toBeVisible() + await expect(campaignPageChrome(page, /Salvador — ZE \d+/)).toBeVisible() }) diff --git a/tests/e2e/fixtures/campaignE2EFixtures.ts b/tests/e2e/fixtures/campaignE2EFixtures.ts index 8d5b145e..5ca937a3 100644 --- a/tests/e2e/fixtures/campaignE2EFixtures.ts +++ b/tests/e2e/fixtures/campaignE2EFixtures.ts @@ -561,11 +561,11 @@ export const test = base.extend({ }, }) -export const campaignPageChrome = (page: Page, title: string) => +export const campaignPageChrome = (page: Page, title: string | RegExp) => page .locator('[data-slot="campaign-page-chrome"]') .filter({ visible: true }) .locator('[data-slot="campaign-page-chrome-title"]') - .getByText(title, { exact: true }) + .getByText(title, typeof title === 'string' ? { exact: true } : undefined) export { expect } From ffe7eca62949125266d61df0f0cf172c5e5cb7c3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 23:33:55 +0000 Subject: [PATCH 5/5] =?UTF-8?q?test(e2e):=20assert=20assessoria=20on=20dos?= =?UTF-8?q?si=C3=AA=20tab=20after=20B145=20hero=20removal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/e2e/campaignMunicipalities.e2e.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/e2e/campaignMunicipalities.e2e.spec.ts b/tests/e2e/campaignMunicipalities.e2e.spec.ts index 2614000b..7fc2fb85 100644 --- a/tests/e2e/campaignMunicipalities.e2e.spec.ts +++ b/tests/e2e/campaignMunicipalities.e2e.spec.ts @@ -75,6 +75,9 @@ test.describe('Municípios — jornadas por papel', () => { // unique, unambiguous string rather than a bare "5.000" substring — // that also matches the (opacity-0 but DOM-visible) hover-preview span. await expect(page.getByText('Otimista: 5.000')).toBeVisible() + + // B145: assessoria left the detail hero; it still appears on the dossiê tab. + await page.goto(`${campaign.baseURL}/campanha/municipios/${municipality.slug}?tab=dossie`) await expect(page.getByText(`Assessoria: ${advisor.name}`)).toBeVisible() })