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.city ?? 'Cidade não informada'} - {supporter.territory ? ` · ${supporter.territory}` : ''} -
-+ {supporter.city ?? 'Cidade não informada'} + {supporter.territory ? ` · ${supporter.territory}` : ''} +
- 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. +
- {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))} -
-+ + {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 ? ({stateDeputy.notes}
- ) : null} -{stateDeputy.notes}
+ ) : null}- {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} - {leadership.email ? ` · ${leadership.email}` : ''} -
- {leadership.stateDeputies.length > 0 ? ( -+ {leadership.phone ? `Celular ${leadership.phone}` : 'Sem celular registrado'} + {leadership.email ? ` · ${leadership.email}` : ''} +
+ {leadership.stateDeputies.length > 0 ? ( +{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))}` - : ''} -
-- Atua em {organization.municipalityNames.join(', ')} -
- ) : null} - {organization.notes ? ( -{organization.notes}
- ) : null} -+ Atua em {organization.municipalityNames.join(', ')} +
+ ) : null} + {organization.notes ? ( +{organization.notes}
+ ) : null}