From 037785b280e07d26845cbe0b7e1deab246887655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 03:25:12 -0300 Subject: [PATCH 1/2] B75: wizard mobile header chrome on Mandate Red bar. Publish flow navigation from CampaignWizardShell via layout-level chrome so wizards no longer stack a second sticky header under the app bar. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + docs/plans/header-mobile-wizard-campanha.md | 10 +- docs/roadmap.md | 14 +- .../campanha/(app)/acoes/layout.tsx | 6 +- src/app/(campaign)/campanha/(app)/layout.tsx | 38 ++--- .../campaign/shared/CampaignWizardShell.tsx | 68 ++++---- .../shared/WizardExpectedVotesStep.tsx | 7 + .../shared/WizardMunicipalitySearchStep.tsx | 9 +- .../shared/WizardMunicipalitySelectedStub.tsx | 10 +- .../campaign/shell/CampaignMobileTopBar.tsx | 107 +++++++++++++ .../shell/CampaignWizardChromeContext.tsx | 95 +++++++++++ src/lib/campaignActionRoutes.ts | 10 ++ src/lib/campaignHomeActions.ts | 11 +- src/lib/campaignWizardCopy.ts | 21 ++- tests/e2e/campaignWizardChrome.e2e.spec.ts | 55 +++++++ tests/e2e/setup.e2e.spec.ts | 1 + tests/helpers/matchMedia.ts | 17 ++ tests/unit/campaignActionRoutes.unit.spec.ts | 6 + tests/unit/campaignMobileTopBar.unit.spec.tsx | 146 +++++++++++++++++ tests/unit/campaignWizardCopy.unit.spec.ts | 22 +++ tests/unit/campaignWizardShell.unit.spec.tsx | 147 ++++++++++++------ .../wizardExpectedVotesStep.unit.spec.tsx | 28 +++- 22 files changed, 708 insertions(+), 121 deletions(-) create mode 100644 src/components/campaign/shell/CampaignMobileTopBar.tsx create mode 100644 src/components/campaign/shell/CampaignWizardChromeContext.tsx create mode 100644 tests/e2e/campaignWizardChrome.e2e.spec.ts create mode 100644 tests/helpers/matchMedia.ts create mode 100644 tests/unit/campaignMobileTopBar.unit.spec.tsx create mode 100644 tests/unit/campaignWizardCopy.unit.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 5f8365a8..efe29c9d 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B75 entregue em código (2026-07-30):** header mobile do wizard — em `/campanha/acoes/*` no mobile (`< md`), o Mandate Red deixa de ser sidebar+marca e vira chrome de fluxo: título do catálogo B45 (`wizardFlowTitleForSlug`), subtítulo do município após B60, 1ª etapa só X (`aria-label="Sair da ação"`), 2ª+ Voltar+X; `skip` substitui X quando encadeado (B63+). `CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` (handoff entre passos sem flash app-mode); desktop mantém caption muted no `
` (`md:block`). Helpers: `campaignWizardActionIdForSlug`, `wizardFlowTitleForActionId`, `toCampaignWizardChromeState`. `data-wizard-route` em `acoes/layout.tsx`. Unit `campaignMobileTopBar`, `campaignWizardShell`, `campaignWizardCopy`; e2e `campaignWizardChrome.e2e.spec.ts` (viewport 390). Débito adiado: skip no desktop; split read/write context do provider (gatilho: re-render medido no inset). Plano: [`header-mobile-wizard-campanha.md`](../../../docs/plans/header-mobile-wizard-campanha.md). Desbloqueia polish mobile de B63/B64/B70/B77/B78 sobre chrome único. - **B79–B90 registrados (2026-07-30):** pós-**B73** (remover bottom nav), chrome mobile fora do Início = bottom drawer **não-modal** (ações contextuais + busca global) — chassis **B79** + catálogos por vertical **B80–B90** (Municípios, Territórios, Lideranças, Dobradinhas, Atividades, Demandas, Apoiadores, Assessores, Organizações, Contatos leader, Conceitos+Perfil). **Quadro fora** do inventário. Granularidade = uma vertical (lista+detalhe). Lote inteiro **não cortável**. Planos em `docs/plans/chassis-bottom-drawer-acoes-rapidas.md` e `acoes-rapidas-*.md`. - **B77 registrado (2026-07-30):** simplificar wizard ajuste de votos — três cenários numa tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm com highlight; substitui ritual `?cenario=` de B61/B76. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). - **B71 entregue em código (2026-07-30):** polimento visual dos resultados de busca no Início (UX-1) — `src/lib/homeSearchUi.ts` (`HOME_SEARCH_HIT_ROW_WRAPPER_CLASS` = bleed + hover + `focus-within`; heading/list); `HomeSearchHitRow` faixa full-bleed `-mx-4 px-4 md:-mx-6 md:px-6` (mantém `onMouseDown` preventDefault de B66); todos os `HomeSearch*Group` com `ul list-none` e título `text-xs` sentence-case (sem `uppercase`/`tracking-wide`). Unit `homeSearchUi.unit.spec.tsx`. Sem migration/action/JSON. Plano: [`polimento-resultados-busca-inicio.md`](../../../docs/plans/polimento-resultados-busca-inicio.md). diff --git a/docs/plans/header-mobile-wizard-campanha.md b/docs/plans/header-mobile-wizard-campanha.md index a3dc91c9..23681288 100644 --- a/docs/plans/header-mobile-wizard-campanha.md +++ b/docs/plans/header-mobile-wizard-campanha.md @@ -1,6 +1,6 @@ # Header mobile do wizard (chrome no Mandate Red) -Status: rascunho +Status: entregue (2026-07-30) Atualizado em: 2026-07-30 Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B — **B75**; chassis UX-1 / emenda a B59) Impeccable: C — chrome novo no shell `(app)` durante `/campanha/acoes/*`; brief abaixo @@ -132,4 +132,12 @@ Componentes: - `PRODUCT.md` / `DESIGN.md` — Mandate Red / Field Desk - AGENTS.md — naming; sem Consent novo +## As-built (2026-07-30) + +- `CampaignWizardChromeProvider` envolve o inset em `(app)/layout.tsx`; `CampaignMobileTopBar` troca modo app (sidebar+marca) ↔ modo wizard quando o shell publica chrome. +- `CampaignWizardShell` chama `useSetCampaignWizardChrome` (cleanup no unmount); mobile sem `
` sticky branco; desktop mantém caption de município no `
` (`md:block`). +- `wizardFlowTitleForSlug` deriva o título do catálogo B45 via `campaignWizardActionIdForSlug` + `wizardFlowTitleForActionId`. +- Skip encadeado (B63/B64/B70) usa prop `skip` no shell — slot direito do header no mobile; desktop adiado (débito no plano). +- Testes: unit `campaignMobileTopBar`, `campaignWizardShell`; e2e smoke `campaignWizardChrome.e2e.spec.ts`; prewarm `/campanha/acoes/atualizar-votos` no `setup.e2e`. + Qualidade de decisão: 4/5 diff --git a/docs/roadmap.md b/docs/roadmap.md index 28ee603d..350cb464 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; **B77** simplificar ajuste votos; **B74** gap strip v2; **B75** header wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B77/B78. +Atualizado em: 2026-07-30 — **B79–B90** bottom drawer de ações rápidas + busca (chassis + catálogos por vertical; sem Quadro; lote não cortável); **B78** pending nav wizard; **B77** simplificar ajuste votos; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards B63/B64/B70/B77/B78. Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc). ## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026) @@ -83,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re ### Demais itens abertos -- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B75** (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ → **B77** (ajuste votos numa tela) /**B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança). +- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → ~~**B66**~~ ✓ (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → ~~**B71**~~ ✓ (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → **B74** (gap strip v2) → **B73** (remover bottom nav mobile) → **B79** (bottom drawer ações+busca fora do Início) → **B80–B90** (catálogos por vertical; Quadro fora) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → ~~**B75**~~ ✓ (header mobile wizard) → **B78** (pending navegação shell) → ~~**B60**~~ ✓/~~**B61**~~ ✓/~~**B76**~~ ✓ → **B77** (ajuste votos numa tela) /**B64** · ~~**B62**~~ ✓ → **B63** · ~~**B69**~~ ✓ → **B70** (A4 liderança). - ~~**B43** Início em branco + Quadro~~ — **entregue 2026-07-29** (`/campanha` blank; staff dashboard em `/campanha/quadro`; liderança em `/campanha/contatos`; nav Início+Quadro / Início+Meus contatos; `requireCampaignPageActor` manda leader bloqueado → `/campanha/contatos`; sem migration) · [plano](plans/inicio-em-branco-quadro.md) - ~~**B44** Botão de ação do Início + lista horizontal~~ — **entregue 2026-07-29** (`CampaignHomeActionButton` + `CampaignHomeActionStrip`; Tooltip fine / long-press Drawer coarse; montado no Início por B45 ✓) · ~1d · Janela 1 · Impeccable C · [plano](plans/botao-acao-inicio-strip.md) - ~~**B45** Catálogo de ações por persona~~ — **entregue 2026-07-29** (`src/lib/campaignHomeActions.ts` + `CampaignHomeActions`; staff 6 ações com `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; leader 2 com `my-contacts` → `/campanha/contatos`; demais inertes; ilha client por limite RSC↔ícones Lucide) · [plano](plans/catalogo-acoes-inicio-por-persona.md) @@ -93,7 +93,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md) - **B61** ~~Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning)~~ — **entregue 2026-07-29** (`WizardExpectedVotesStep`; `?cenario=`; batch POST expected-votes ao concluir otimista; stub B60 substituído só em `atualizar-votos`) · [plano](plans/ajuste-votos-wizard.md) · ~~regressão jump+warning → B76~~ ✓ - **B76** ~~Corrigir coerência do wizard de ajuste de votos~~ — **entregue 2026-07-30** (`useEffect` preserva `violationEditedScenario` no jump ao cenário violado; limpa ao editar draft/atalho; unit `wizardExpectedVotesStep.unit.spec.ts`) · Depende de B61 ✓ · [plano](plans/corrigir-coerencia-wizard-ajuste-votos.md) · UX de três passos → **B77** -- **B77** Simplificar wizard ajuste de votos — três cenários na mesma tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm (warning + highlight, sem `?cenario=` jump) · Depende de B61 ✓ / B76 ✓ · soft B75 · ~0,75–1d · Janela 1 · Paralelizável com B63/B64 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md) +- **B77** Simplificar wizard ajuste de votos — três cenários na mesma tela (strip + inputs como popover da lista); atalhos no input focado; coerência só no confirm (warning + highlight, sem `?cenario=` jump) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · Paralelizável com B63/B64 · Impeccable B · [plano](plans/simplificar-ajuste-votos-wizard.md) - ~~**B62** Simplificar modelo de sinal~~ — **entregue 2026-07-29** (sinal = tipo + texto; migration drop `signal_source`/`triangulated`; labels mesa sem "broker" — `Rede esfriou`/`Adversário apareceu`/`Alguém pediu algo`; E11 sem dimensão triangulado: presença = confirmado, **triage 1**; forms/lista/detalhe/dossiê alinhados; e2e stale-B43 corrigido de bônus) · [plano](plans/simplificar-modelo-sinal.md) - **B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal) · Depende de B59 + B62 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-registro-sinal.md) - **B64** Wizard mudar tendência (grid colorido ≠ atual + Drawer info + justificativa com prefill/Limpar/Salvar + “Pular” sempre) · Depende de B59 · soft B60 · soft B24 ✓ · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/wizard-mudar-tendencia.md) @@ -109,8 +109,8 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re - **B72** ~~Reduzir espaçamento horizontal da strip de ações (`gap-6` → `gap-4`)~~ — **entregue 2026-07-29** (`CampaignHomeActionStrip` `
    ` `gap-4`; botões `w-[4.75rem]` e pan B67 ✓ inalterados) · Depende de B58 ✓ / B67 ✓ (soft) · ~0,25d · Janela 1 · Impeccable B · [plano](plans/espacamento-strip-acoes-inicio.md) - **B73** Remover bottom navigation bar no mobile — paradigma UX-1 intenção-primeiro: apagar `CampaignBottomNav` + `getCampaignBottomNav`; sidebar Sheet (fechada por padrão, B38 ✓) como única nav estrutural; ajustar `pb-24` do scroll global; revalidar dock do Início (B65 ✓) · Depende de B43 ✓ · soft B65 ✓ / B38 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B66 / B71 / B74 · Impeccable B · desbloqueia **B79** · [plano](plans/remover-bottom-nav-mobile.md) - **B74** Apertar mais o espaçamento da strip de ações (`gap-4` → `gap-3`, critique pode `gap-2`) — pós-B72 ainda largo no mobile · Depende de B72 ✓ · soft B67 ✓ · ~0,25d · Janela 1 · Paralelizável com B73 / B66 / B71 · Impeccable B · [plano](plans/espacamento-strip-acoes-inicio-v2.md) -- **B75** Header mobile do wizard — durante `/campanha/acoes/*`, o Mandate Red substitui sidebar+marca: título = fluxo (“Ajustar votos”), subtítulo = município; 1ª etapa só X; 2ª+ Voltar+X; encadeado X→“Pular <fluxo>”; context no `(app)` + shell sem sticky branco no mobile · Depende de B59 ✓ · soft B60 ✓ / B45 ✓ · ~1–1,25d · Janela 1 · Paralelizável com B61/B63/B64 (eles consomem o chrome) · Impeccable C · [plano](plans/header-mobile-wizard-campanha.md) -- **B78** Indicação de carregamento nas ações de navegação do wizard — `CampaignListPendingBoundary` no layout `acoes/`; Voltar/X/Pular/seleção de município dentro de `useCampaignListTransition`; corpo do passo dima (`CampaignListResults`); soft shimmer nos slots título/subtítulo do header Mandate Red (**B75**) · Depende de B59 ✓ · soft B75 / B60 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B77/B63/B64 · Impeccable B · [plano](plans/indicacao-carregamento-navegacao-wizard.md) +- ~~**B75** Header mobile do wizard~~ — **entregue 2026-07-30** (`CampaignWizardChromeProvider` + `CampaignMobileTopBar` no layout `(app)`; `CampaignWizardShell` publica chrome via `useLayoutEffect` sem flash app-mode entre passos; `wizardFlowTitleForSlug`; entry=X / continue=Voltar+X / skip substitui X; e2e `campaignWizardChrome`) · Depende de B59 ✓ · [plano](plans/header-mobile-wizard-campanha.md) +- **B78** Indicação de carregamento nas ações de navegação do wizard — `CampaignListPendingBoundary` no layout `acoes/`; Voltar/X/Pular/seleção de município dentro de `useCampaignListTransition`; corpo do passo dima (`CampaignListResults`); soft shimmer nos slots título/subtítulo do header Mandate Red (**B75** ✓) · Depende de B59 ✓ · soft ~~B75~~ ✓ / B60 ✓ · ~0,25–0,5d · Janela 1 · Paralelizável com B77/B63/B64 · Impeccable B · [plano](plans/indicacao-carregamento-navegacao-wizard.md) - **B79** Chassis do bottom drawer de ações rápidas + busca global (mobile, não-modal, drag/snap; fora do Início e de `/acoes/*`) — preenche o buraco pós-**B73** · Depende de B73 · soft B47 ✓ / B45 ✓ / B60 ✓ · ~1,5–2d · Janela 1 · Impeccable C · **não cortável** · [plano](plans/chassis-bottom-drawer-acoes-rapidas.md) - **B80** Ações rápidas — Municípios (lista + detalhe; detalhe = A1–A5 com `?municipio=` / skip B60) · Depende de B79 · soft B45 ✓ / B60 ✓ · ~0,5–0,75d · Janela 1 · Impeccable B · **não cortável** · [plano](plans/acoes-rapidas-municipios.md) - **B81** Ações rápidas — Territórios · Depende de B79 · soft B21 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · **não cortável** · [plano](plans/acoes-rapidas-territorios.md) @@ -530,11 +530,11 @@ flowchart TD B8F2 -.rank por ZE = débito aberto.-> B14n ``` -Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); **B75** (header mobile wizard — após ~~B59~~ ✓; soft nos wizards restantes B63/B64/B70/B77); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft B75 para shimmer no header; paralelo a B77/B63/B64); **B77** (simplificar ajuste votos — após ~~B61~~ ✓/~~B76~~ ✓; paralelo a B63/B64); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (B75 soft). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft B75). Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. +Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloqueia B79**); **B74** (gap strip v2 — após ~~B72~~ ✓; paralelo a B73); ~~**B75**~~ ✓ (header mobile wizard); **B78** (pending navegação wizard — após ~~B59~~ ✓; soft ~~B75~~ ✓ para shimmer no header; paralelo a B77/B63/B64); **B77** (simplificar ajuste votos — após ~~B61~~ ✓/~~B76~~ ✓; paralelo a B63/B64); **B49–B53** após B47 ✓ / B48 ✓ (depois B54/B55). Após **B79**: **B80–B90** entre si (catálogos por vertical; todos dependem só do chassis). Após **B59**: ~~**B60**~~ ✓ ∥ ~~**B61**~~ ✓ ∥ **B64** (~~B75~~ ✓). **B63** espera B59+B62 ✓. **B70** espera ~~B59+B69~~ ✓ (+ soft ~~B75~~ ✓). Prefills ricos de B64 com B61/B63 são soft. **B57** futuro (depende B56 ✓). Fill-ins / débitos `*+` e itens já ✓ permanecem fora do caminho crítico UX-1. **D3** só após smoke + folga se UX-1 não absorver o Zap. ### Sequência por janela (só pendentes) -**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → B75** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ → **B77** (ajuste votos 1 tela) ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B77/B78/B63–B64 + A4 ~~B69~~ ✓/B70. +**Janela 1 — agora → 05/08 (convenções):** **prioridade pós-29/07 — ~~B43–B48~~ ✓ → ~~B67~~ ✓ (pan strip) **→ ~~B65~~ ✓** (âncora inferior) **→ ~~B66~~ ✓** (focus limpa busca) **→ ~~B68~~ ✓\*\* **→ ~~B71~~ ✓** (polish resultados) **· ~~B72~~ ✓** / **B74** (gap strip) **→ B73** (remover bottom nav) **→ B79** (drawer ações+busca) **→ B80–B90** (catálogos; ∥ entre si) **→** **wizards ~~B59~~ ✓ → ~~B75~~ ✓** (header mobile) **→ B78** (pending navegação) → ~~B60~~ ✓/~~B61~~ ✓/~~B76~~ ✓ → **B77** (ajuste votos 1 tela) ∥B64 · ~~B62~~ ✓ → B63 · ~~B69~~ ✓ → B70 (soft 03/08) · **B49–B55** · [fluxos](plans/fluxos-acao-primeiro-inicio.md); smoke + onboarding Onda 0; **E4R ✓** aplicar seed em produção após smoke; demais entregues/pendentes da janela inalterados no detalhe abaixo — foco operacional = chrome mobile B73/B79–B90 + polish Início B74 + busca B49+ + wizards B77/B78/B63–B64 + A4 ~~B69~~ ✓/B70. **Janela 2 — 05/08 → 16/08 (pré-propaganda):** C2 dados reais assim que o jurídico liberar; ~~**E9** fila de alocação~~ (entregue 2026-07-24, antecipada da janela 2); ~~**E16** dossiê do município~~ (entregue 2026-07-25, antecipado da janela 2); D2 se sobrar folga. diff --git a/src/app/(campaign)/campanha/(app)/acoes/layout.tsx b/src/app/(campaign)/campanha/(app)/acoes/layout.tsx index bd18bf09..7afc6185 100644 --- a/src/app/(campaign)/campanha/(app)/acoes/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/acoes/layout.tsx @@ -5,5 +5,9 @@ import { requireCampaignPageActor } from '@/utilities/campaignPageActor' export default async function CampaignActionsLayout({ children }: { children: ReactNode }) { await requireCampaignPageActor({ gate: 'staff' }) - return
    {children}
    + return ( +
    + {children} +
    + ) } diff --git a/src/app/(campaign)/campanha/(app)/layout.tsx b/src/app/(campaign)/campanha/(app)/layout.tsx index 299d5ed1..ef236ac9 100644 --- a/src/app/(campaign)/campanha/(app)/layout.tsx +++ b/src/app/(campaign)/campanha/(app)/layout.tsx @@ -9,8 +9,10 @@ import { type BiometricEnrollmentOffer, } from '@/components/campaign/shell/BiometricEnrollmentToast' import { CampaignBottomNav } from '@/components/campaign/shell/CampaignBottomNav' +import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar' import { CampaignSidebar } from '@/components/campaign/shell/CampaignSidebar' import { CampaignSidebarViewportDefault } from '@/components/campaign/shell/CampaignSidebarViewportDefault' +import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext' import { InstallPwaToast } from '@/components/campaign/shell/InstallPwaToast' import { SIDEBAR_COOKIE_NAME, @@ -69,28 +71,22 @@ export default async function CampaignAppLayout({ children }: { children: React. -
    - -
    - Jorge Solla - - Campanha · Bahia - + + +
    + +
    +
    + {children}
    -
    -
    - -
    -
    - {children} -
    - - - - {biometricEnrollment ? : null} + + + + {biometricEnrollment ? : null} +
    ) diff --git a/src/components/campaign/shared/CampaignWizardShell.tsx b/src/components/campaign/shared/CampaignWizardShell.tsx index 5dda2993..7ba38df2 100644 --- a/src/components/campaign/shared/CampaignWizardShell.tsx +++ b/src/components/campaign/shared/CampaignWizardShell.tsx @@ -1,25 +1,36 @@ 'use client' -import { ArrowLeft } from 'lucide-react' -import Link from 'next/link' -import { useEffect, useId, useRef, type ReactNode } from 'react' +import { useEffect, useId, useMemo, useRef, type ReactNode } from 'react' -import { Button } from '@/components/ui/button' +import { + toCampaignWizardChromeState, + useSetCampaignWizardChrome, + type CampaignWizardChromeSkip, +} from '@/components/campaign/shell/CampaignWizardChromeContext' +import { CAMPAIGN_HOME } from '@/lib/campaignPaths' import { cn } from '@/lib/utils' export type CampaignWizardShellProps = { + flowTitle: string stepTitle: string + isEntryStep: boolean previousHref: string + dismissHref?: string municipalityLabel?: string + skip?: CampaignWizardChromeSkip trailingAction?: ReactNode contentAlign?: 'start' | 'end' children: ReactNode } export const CampaignWizardShell = ({ + flowTitle, stepTitle, + isEntryStep, previousHref, + dismissHref = CAMPAIGN_HOME, municipalityLabel, + skip, trailingAction, contentAlign = 'start', children, @@ -27,40 +38,28 @@ export const CampaignWizardShell = ({ const titleRef = useRef(null) const titleId = useId() + const chrome = useMemo( + () => + toCampaignWizardChromeState({ + flowTitle, + isEntryStep, + previousHref, + dismissHref, + municipalityLabel, + skip, + }), + [dismissHref, flowTitle, isEntryStep, municipalityLabel, previousHref, skip], + ) + + useSetCampaignWizardChrome(chrome) + useEffect(() => { titleRef.current?.focus() }, [stepTitle]) return (
    -
    -
    -
    - -
    - {municipalityLabel ? ( -

    - {municipalityLabel} -

    - ) : null} - {trailingAction ? ( -
    - {trailingAction} -
    - ) : null} -
    -
    + {trailingAction ?
    {trailingAction}
    : null}
    + {municipalityLabel ? ( +

    + {municipalityLabel} +

    + ) : null}

    @@ -198,8 +202,11 @@ export const WizardExpectedVotesStep = ({ return (
    +
    (

    {wizardNextStepPlaceholder(actionSlug)}

    diff --git a/src/components/campaign/shell/CampaignMobileTopBar.tsx b/src/components/campaign/shell/CampaignMobileTopBar.tsx new file mode 100644 index 00000000..2fd481fb --- /dev/null +++ b/src/components/campaign/shell/CampaignMobileTopBar.tsx @@ -0,0 +1,107 @@ +'use client' + +import { ArrowLeft, X } from 'lucide-react' +import Link from 'next/link' + +import { useCampaignWizardChrome } from '@/components/campaign/shell/CampaignWizardChromeContext' +import { Button } from '@/components/ui/button' +import { SidebarTrigger } from '@/components/ui/Sidebar' +import { + WIZARD_APP_TOP_BAR_ARIA_LABEL, + WIZARD_DISMISS_ARIA_LABEL, + wizardFlowChromeAriaLabel, + wizardMunicipalityChromeAriaLabel, +} from '@/lib/campaignWizardCopy' +import { cn } from '@/lib/utils' + +export const CampaignMobileTopBar = () => { + const chrome = useCampaignWizardChrome() + + if (chrome) { + return ( +
    +
    + {chrome.stepKind === 'continue' && chrome.previousHref ? ( + + ) : ( +
    + )} + +
    + {chrome.flowTitle} + {chrome.municipalityLabel ? ( + + {chrome.municipalityLabel} + + ) : null} +
    +
    + +
    + {chrome.skip ? ( + + ) : ( + + )} +
    +
    + ) + } + + return ( +
    + +
    + Jorge Solla + Campanha · Bahia +
    +
    + ) +} diff --git a/src/components/campaign/shell/CampaignWizardChromeContext.tsx b/src/components/campaign/shell/CampaignWizardChromeContext.tsx new file mode 100644 index 00000000..79a0cf37 --- /dev/null +++ b/src/components/campaign/shell/CampaignWizardChromeContext.tsx @@ -0,0 +1,95 @@ +'use client' + +import { + createContext, + useContext, + useLayoutEffect, + useMemo, + useState, + type Dispatch, + type ReactNode, + type SetStateAction, +} from 'react' + +export type CampaignWizardChromeSkip = { + label: string + href: string +} + +export type CampaignWizardChromeState = { + flowTitle: string + municipalityLabel?: string + stepKind: 'entry' | 'continue' + previousHref?: string + dismissHref: string + skip?: CampaignWizardChromeSkip +} + +type CampaignWizardChromeContextValue = { + chrome: CampaignWizardChromeState | null + setChrome: Dispatch> +} + +const CampaignWizardChromeContext = createContext(null) + +export const CampaignWizardChromeProvider = ({ children }: { children: ReactNode }) => { + const [chrome, setChrome] = useState(null) + const value = useMemo(() => ({ chrome, setChrome }), [chrome]) + + return ( + + {children} + + ) +} + +const useCampaignWizardChromeContext = (): CampaignWizardChromeContextValue => { + const value = useContext(CampaignWizardChromeContext) + if (!value) { + throw new Error('CampaignWizardChromeProvider is required') + } + return value +} + +export const useCampaignWizardChrome = (): CampaignWizardChromeState | null => + useCampaignWizardChromeContext().chrome + +type CampaignWizardChromeInput = { + flowTitle: string + isEntryStep: boolean + previousHref: string + dismissHref: string + municipalityLabel?: string + skip?: CampaignWizardChromeSkip +} + +export const toCampaignWizardChromeState = ({ + flowTitle, + isEntryStep, + previousHref, + dismissHref, + municipalityLabel, + skip, +}: CampaignWizardChromeInput): CampaignWizardChromeState => ({ + flowTitle, + municipalityLabel, + stepKind: isEntryStep ? 'entry' : 'continue', + previousHref: isEntryStep ? undefined : previousHref, + dismissHref, + skip, +}) + +export const useSetCampaignWizardChrome = (state: CampaignWizardChromeState | null): void => { + const { setChrome } = useCampaignWizardChromeContext() + + useLayoutEffect(() => { + setChrome(state) + }, [setChrome, state]) + + useLayoutEffect( + () => () => { + setChrome(null) + }, + [setChrome], + ) +} diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts index df8a4df7..0b2bf5e1 100644 --- a/src/lib/campaignActionRoutes.ts +++ b/src/lib/campaignActionRoutes.ts @@ -22,8 +22,18 @@ export const CAMPAIGN_WIZARD_ACTION_SLUGS: Record [ + actionSlug, + id as CampaignWizardActionId, + ]), +) as Record + export const isCampaignWizardActionSlug = (slug: string): boolean => wizardActionSlugs.has(slug) +export const campaignWizardActionIdForSlug = (slug: string): CampaignWizardActionId | undefined => + campaignWizardSlugToId[slug] + export const isCampaignWizardActionId = (id: string): id is CampaignWizardActionId => id in CAMPAIGN_WIZARD_ACTION_SLUGS diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts index 67287d0b..40da5fc4 100644 --- a/src/lib/campaignHomeActions.ts +++ b/src/lib/campaignHomeActions.ts @@ -10,7 +10,11 @@ import { Users, } from 'lucide-react' -import { campaignActionEntryHref, isCampaignWizardActionId } from '@/lib/campaignActionRoutes' +import { + campaignActionEntryHref, + isCampaignWizardActionId, + type CampaignWizardActionId, +} from '@/lib/campaignActionRoutes' import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import type { CampaignRole } from '@/lib/campaignRoles' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -104,6 +108,11 @@ const leaderHomeActions: readonly CampaignHomeAction[] = [ }, ] +export const wizardFlowTitleForActionId = (id: CampaignWizardActionId): string => { + const action = staffHomeActionsCoordinator.find((entry) => entry.id === id) + return action?.label ?? 'Continuar' +} + export const homeActionsForRole = (role: CampaignRole): readonly CampaignHomeAction[] => { if (role === 'leader') return leaderHomeActions if (!isStaffCampaignRole(role)) return [] diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts index 724ae89a..4922b562 100644 --- a/src/lib/campaignWizardCopy.ts +++ b/src/lib/campaignWizardCopy.ts @@ -1,8 +1,25 @@ -import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes' +import { + CAMPAIGN_WIZARD_ACTION_SLUGS, + campaignWizardActionIdForSlug, +} from '@/lib/campaignActionRoutes' +import { wizardFlowTitleForActionId } from '@/lib/campaignHomeActions' -/** Shared copy for UX-1 wizard steps (B60 municipality search, B61 vote adjustment). */ export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const +export const WIZARD_DISMISS_ARIA_LABEL = 'Sair da ação' as const + +export const WIZARD_APP_TOP_BAR_ARIA_LABEL = 'Campanha Jorge Solla' as const + +export const wizardMunicipalityChromeAriaLabel = (municipalityLabel: string): string => + `Município em atualização: ${municipalityLabel}` + +export const wizardFlowChromeAriaLabel = (flowTitle: string): string => `Ação: ${flowTitle}` + +export const wizardFlowTitleForSlug = (actionSlug: string): string => { + const actionId = campaignWizardActionIdForSlug(actionSlug) + return actionId ? wizardFlowTitleForActionId(actionId) : 'Continuar' +} + export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as const diff --git a/tests/e2e/campaignWizardChrome.e2e.spec.ts b/tests/e2e/campaignWizardChrome.e2e.spec.ts new file mode 100644 index 00000000..e070f269 --- /dev/null +++ b/tests/e2e/campaignWizardChrome.e2e.spec.ts @@ -0,0 +1,55 @@ +import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js' +import { expect, test } from './fixtures/campaignE2EFixtures.js' + +test.use({ viewport: { width: 390, height: 844 } }) + +test.describe('Wizard — header mobile (B75)', () => { + test('entry step shows wizard flow title and dismiss without sidebar trigger', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/acoes/atualizar-votos') + + const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]') + await expect(topBar).toBeVisible() + await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible() + await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible() + await expect(page.getByRole('link', { name: 'Sair da ação' })).toBeVisible() + await expect(page.getByRole('link', { name: /Voltar/ })).toHaveCount(0) + + await page.getByRole('link', { name: 'Sair da ação' }).click() + await page.waitForURL(/\/campanha\/?$/) + }) + + test('continue step shows back control and municipality in wizard header', async ({ + campaign, + page, + }) => { + const { fixtures } = campaign + const coordinator = await fixtures.createCampaignUser('coordinator', { + name: fixtures.value('Coordenadora Geral'), + }) + + await campaign.login(page, coordinator.email!, coordinator.password) + await page.goto('/campanha/acoes/atualizar-votos?municipio=cairu') + + await expect(page.getByRole('heading', { name: /Qual a nova estimativa média/i })).toBeVisible({ + timeout: 15000, + }) + + const topBar = page.locator('[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]') + await expect(topBar.getByText('Ajustar votos', { exact: true })).toBeVisible() + await expect(topBar.getByLabel(/Município em atualização: Cairu/i)).toBeVisible() + await expect(page.getByRole('link', { name: /Voltar/ })).toBeVisible() + + await page.getByRole('link', { name: /Voltar/ }).click() + await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\/?$/) + await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible() + }) +}) diff --git a/tests/e2e/setup.e2e.spec.ts b/tests/e2e/setup.e2e.spec.ts index b4a03a44..b5c7194c 100644 --- a/tests/e2e/setup.e2e.spec.ts +++ b/tests/e2e/setup.e2e.spec.ts @@ -15,6 +15,7 @@ test('prewarms shared Next route bundles sequentially', async ({ request }) => { '/campanha/demandas', '/campanha/liderancas', '/campanha/conceitos', + '/campanha/acoes/atualizar-votos', '/campanha/convite/e2e-prewarm', '/', ]) { diff --git a/tests/helpers/matchMedia.ts b/tests/helpers/matchMedia.ts new file mode 100644 index 00000000..fd1a53bd --- /dev/null +++ b/tests/helpers/matchMedia.ts @@ -0,0 +1,17 @@ +import { vi } from 'vitest' + +export const stubMatchMedia = (matches = false): void => { + vi.stubGlobal( + 'matchMedia', + vi.fn().mockImplementation((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ) +} diff --git a/tests/unit/campaignActionRoutes.unit.spec.ts b/tests/unit/campaignActionRoutes.unit.spec.ts index 600bec39..8aa04294 100644 --- a/tests/unit/campaignActionRoutes.unit.spec.ts +++ b/tests/unit/campaignActionRoutes.unit.spec.ts @@ -4,6 +4,7 @@ import { CAMPAIGN_ACTIONS_HOME, CAMPAIGN_WIZARD_ACTION_SLUGS, campaignActionEntryHref, + campaignWizardActionIdForSlug, isCampaignWizardActionId, isCampaignWizardActionSlug, parseWizardMunicipioParam, @@ -55,4 +56,9 @@ describe('campaignActionRoutes', () => { expect(parseWizardMunicipioParam(undefined)).toBeUndefined() expect(parseWizardMunicipioParam(' ')).toBeUndefined() }) + + it('resolves wizard action id from slug', () => { + expect(campaignWizardActionIdForSlug('atualizar-votos')).toBe('update-votes') + expect(campaignWizardActionIdForSlug('inventado')).toBeUndefined() + }) }) diff --git a/tests/unit/campaignMobileTopBar.unit.spec.tsx b/tests/unit/campaignMobileTopBar.unit.spec.tsx new file mode 100644 index 00000000..d6805d81 --- /dev/null +++ b/tests/unit/campaignMobileTopBar.unit.spec.tsx @@ -0,0 +1,146 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar' +import { + CampaignWizardChromeProvider, + useSetCampaignWizardChrome, + type CampaignWizardChromeState, +} from '@/components/campaign/shell/CampaignWizardChromeContext' +import { SidebarProvider } from '@/components/ui/Sidebar' +import { stubMatchMedia } from '../helpers/matchMedia' + +const ChromeProbe = ({ chrome }: { chrome: CampaignWizardChromeState | null }) => { + useSetCampaignWizardChrome(chrome) + return null +} + +const renderTopBar = (chrome: CampaignWizardChromeState | null) => + render( + + + + + + , + ) + +const renderWizardTopBar = (chrome: CampaignWizardChromeState) => + render( + + + + + + , + ) + +describe('CampaignMobileTopBar', () => { + beforeEach(() => { + stubMatchMedia() + }) + + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) + + it('renders app mode with sidebar trigger when chrome is null', () => { + const { container } = renderTopBar(null) + + 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.queryByRole('link', { name: /Voltar/ })).toBeNull() + expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull() + }) + + it('renders wizard entry mode with dismiss control and flow title', () => { + renderWizardTopBar({ + flowTitle: 'Ajustar votos', + stepKind: 'entry', + dismissHref: '/campanha', + }) + + expect(screen.getByText('Ajustar votos')).toBeTruthy() + expect(screen.getByRole('link', { name: 'Sair da ação' }).getAttribute('href')).toBe( + '/campanha', + ) + expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull() + }) + + it('renders wizard continue mode with back link and municipality subtitle', () => { + renderWizardTopBar({ + flowTitle: 'Ajustar votos', + municipalityLabel: 'Cairu', + stepKind: 'continue', + previousHref: '/campanha/acoes/atualizar-votos', + dismissHref: '/campanha', + }) + + expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe( + '/campanha/acoes/atualizar-votos', + ) + expect(screen.getByLabelText('Município em atualização: Cairu').textContent).toBe('Cairu') + expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy() + }) + + it('renders skip link instead of dismiss when chained flow defines skip', () => { + renderWizardTopBar({ + flowTitle: 'Registrar sinal', + stepKind: 'continue', + previousHref: '/campanha/acoes/atualizar-votos?municipio=cairu', + dismissHref: '/campanha', + skip: { + label: 'Pular registro de sinal', + href: '/campanha/acoes/mudar-tendencia?municipio=cairu', + }, + }) + + expect(screen.getByRole('link', { name: 'Pular registro de sinal' }).getAttribute('href')).toBe( + '/campanha/acoes/mudar-tendencia?municipio=cairu', + ) + expect(screen.queryByRole('link', { name: 'Sair da ação' })).toBeNull() + }) + + it('keeps wizard mode when chrome updates between steps', () => { + const { rerender } = render( + + + + + + , + ) + + expect(screen.queryByText('Jorge Solla')).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.getByLabelText('Município em atualização: Cairu')).toBeTruthy() + }) +}) diff --git a/tests/unit/campaignWizardCopy.unit.spec.ts b/tests/unit/campaignWizardCopy.unit.spec.ts new file mode 100644 index 00000000..5ba27e48 --- /dev/null +++ b/tests/unit/campaignWizardCopy.unit.spec.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { + WIZARD_DISMISS_ARIA_LABEL, + wizardFlowChromeAriaLabel, + wizardFlowTitleForSlug, + wizardMunicipalityChromeAriaLabel, +} from '@/lib/campaignWizardCopy' + +describe('campaignWizardCopy', () => { + it('maps wizard slugs to home action flow titles', () => { + expect(wizardFlowTitleForSlug('atualizar-votos')).toBe('Ajustar votos') + expect(wizardFlowTitleForSlug('registrar-sinal')).toBe('Registrar sinal') + expect(wizardFlowTitleForSlug('inventado')).toBe('Continuar') + }) + + it('builds wizard chrome aria labels from shared copy', () => { + expect(wizardFlowChromeAriaLabel('Ajustar votos')).toBe('Ação: Ajustar votos') + expect(wizardMunicipalityChromeAriaLabel('Cairu')).toBe('Município em atualização: Cairu') + expect(WIZARD_DISMISS_ARIA_LABEL).toBe('Sair da ação') + }) +}) diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx index 81f8b42b..b5fd91cf 100644 --- a/tests/unit/campaignWizardShell.unit.spec.tsx +++ b/tests/unit/campaignWizardShell.unit.spec.tsx @@ -1,33 +1,78 @@ import { cleanup, render, screen, within } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' +import { CampaignMobileTopBar } from '@/components/campaign/shell/CampaignMobileTopBar' +import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext' +import { SidebarProvider } from '@/components/ui/Sidebar' import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy' +import { stubMatchMedia } from '../helpers/matchMedia' + +const renderWizardShell = (props: React.ComponentProps) => + render( + + + + + + , + ) describe('CampaignWizardShell', () => { - afterEach(() => { - cleanup() + beforeEach(() => { + stubMatchMedia() }) - it('renders mobile back link with visible Voltar label', () => { - const { container } = render( - - Corpo - , - ) + afterEach(() => { + cleanup() + vi.unstubAllGlobals() + }) - const back = screen.getByRole('link', { name: /Voltar/ }) - expect(back.getAttribute('href')).toBe('/campanha') - const mobileBackSlot = container.querySelector('[data-slot="wizard-mobile-back"]') - expect(mobileBackSlot?.contains(back)).toBe(true) + it('registers wizard chrome on the mobile top bar for continue steps', () => { + renderWizardShell({ + flowTitle: 'Ajustar votos', + stepTitle: 'Quantos votos?', + isEntryStep: false, + previousHref: '/campanha/acoes/atualizar-votos', + dismissHref: '/campanha', + municipalityLabel: 'Cairu', + children: 'Corpo', + }) + + expect(screen.getByRole('link', { name: /Voltar/ }).getAttribute('href')).toBe( + '/campanha/acoes/atualizar-votos', + ) + const topBar = document.querySelector( + '[data-slot="campaign-mobile-top-bar"][data-mode="wizard"]', + ) + expect(topBar).toBeTruthy() + expect( + within(topBar as HTMLElement).getByLabelText('Município em atualização: Cairu'), + ).toBeTruthy() + }) + + it('registers entry chrome without back control', () => { + renderWizardShell({ + flowTitle: 'Ajustar votos', + stepTitle: WIZARD_MUNICIPALITY_STEP_TITLE, + isEntryStep: true, + previousHref: '/campanha', + dismissHref: '/campanha', + children: 'Corpo', + }) + + expect(screen.getByRole('link', { name: 'Sair da ação' })).toBeTruthy() + expect(screen.queryByRole('link', { name: /Voltar/ })).toBeNull() }) it('labels main landmark with the step title', () => { - const { container } = render( - - Corpo - , - ) + const { container } = renderWizardShell({ + flowTitle: 'Ajustar votos', + stepTitle: 'Pergunta', + isEntryStep: true, + previousHref: '/campanha', + children: 'Corpo', + }) const main = within(container).getByRole('main') const heading = within(container).getByRole('heading', { level: 1, name: 'Pergunta' }) @@ -36,42 +81,52 @@ describe('CampaignWizardShell', () => { it('moves focus to the step title when stepTitle changes', () => { const { rerender } = render( - - Corpo - , + + + + + Corpo + + + , ) rerender( - - Corpo - , + + + + + Corpo + + + , ) expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' })) }) - it('omits municipality caption when label is absent', () => { - render( - - {null} - , - ) + it('shows municipality caption in main on desktop breakpoint classes', () => { + const { container } = renderWizardShell({ + flowTitle: 'Ajustar votos', + stepTitle: 'Pergunta', + isEntryStep: false, + previousHref: '/campanha/acoes/atualizar-votos', + municipalityLabel: 'Cairu', + children: null, + }) - expect(screen.queryByLabelText(/Município em atualização/)).toBeNull() - }) - - it('shows municipality caption with accessible name', () => { - render( - - {null} - , - ) - - const caption = screen.getByLabelText('Município em atualização: Cairu') - expect(caption.textContent).toBe('Cairu') + const caption = container.querySelector('main p') + expect(caption?.textContent).toBe('Cairu') + expect(caption?.className).toMatch(/md:block/) }) }) diff --git a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx index de1e8ca3..14abbe3e 100644 --- a/tests/unit/wizardExpectedVotesStep.unit.spec.tsx +++ b/tests/unit/wizardExpectedVotesStep.unit.spec.tsx @@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, within } from '@testing-library/rea import { afterEach, describe, expect, it, vi } from 'vitest' import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep' +import { CampaignWizardChromeProvider } from '@/components/campaign/shell/CampaignWizardChromeContext' const replace = vi.fn() @@ -29,6 +30,13 @@ const defaultProps = { initialExpectedVotes: { pessimistic: 100, central: 200, optimistic: 300 }, } +const renderVotesStep = (props: React.ComponentProps) => + render( + + + , + ) + describe('WizardExpectedVotesStep', () => { afterEach(() => { cleanup() @@ -36,16 +44,18 @@ describe('WizardExpectedVotesStep', () => { }) it('shows violation banner on média immediately after pessimista breaks order', () => { - const { rerender } = render( - , - ) + const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' }) fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } }) fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' })) expect(replace).toHaveBeenCalledWith(expect.stringContaining('cenario=central')) - rerender() + rerender( + + + , + ) const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i }) expect(within(step).getByRole('alert').textContent).toMatch(/Pessimista/i) @@ -53,13 +63,15 @@ describe('WizardExpectedVotesStep', () => { }) it('clears violation banner when média draft is edited', () => { - const { rerender } = render( - , - ) + const { rerender } = renderVotesStep({ ...defaultProps, currentScenario: 'pessimistic' }) fireEvent.change(screen.getByLabelText(/Pessimista em Cairu/i), { target: { value: '900' } }) fireEvent.click(screen.getByRole('button', { name: 'Ajustar estimativa pessimista →' })) - rerender() + rerender( + + + , + ) const step = screen.getByRole('main', { name: /Qual a nova estimativa média/i }) expect(within(step).getByRole('alert')).toBeTruthy() From 9e19f537158e620cfdf80ecc563e799ad0b39c5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Thu, 30 Jul 2026 03:27:12 -0300 Subject: [PATCH 2/2] docs: mark B75 delivered in roadmap after rebase merge. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fold parallelizáveis/janela updates and simplify deferrals into the B75 plan. --- docs/plans/header-mobile-wizard-campanha.md | 9 ++++++++- docs/roadmap.md | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/plans/header-mobile-wizard-campanha.md b/docs/plans/header-mobile-wizard-campanha.md index 23681288..d69ea02f 100644 --- a/docs/plans/header-mobile-wizard-campanha.md +++ b/docs/plans/header-mobile-wizard-campanha.md @@ -135,9 +135,16 @@ Componentes: ## As-built (2026-07-30) - `CampaignWizardChromeProvider` envolve o inset em `(app)/layout.tsx`; `CampaignMobileTopBar` troca modo app (sidebar+marca) ↔ modo wizard quando o shell publica chrome. -- `CampaignWizardShell` chama `useSetCampaignWizardChrome` (cleanup no unmount); mobile sem `
    ` sticky branco; desktop mantém caption de município no `
    ` (`md:block`). +- `CampaignWizardShell` chama `useSetCampaignWizardChrome` (`useLayoutEffect` para publish + cleanup só no unmount; `/simplify` corrigiu flash app-mode entre passos); mobile sem `
    ` sticky branco; desktop mantém caption de município no `
    ` (`md:block`). - `wizardFlowTitleForSlug` deriva o título do catálogo B45 via `campaignWizardActionIdForSlug` + `wizardFlowTitleForActionId`. - Skip encadeado (B63/B64/B70) usa prop `skip` no shell — slot direito do header no mobile; desktop adiado (débito no plano). - Testes: unit `campaignMobileTopBar`, `campaignWizardShell`; e2e smoke `campaignWizardChrome.e2e.spec.ts`; prewarm `/campanha/acoes/atualizar-votos` no `setup.e2e`. +## Explicitamente fora (pós-/simplify, auto-confirm close-delivery) + +- **Split read/write context do provider** — defer até medição de re-render no inset ao trocar passo (`setChrome` hoje re-renderiza filhos do provider). +- **Mover provider só para `acoes/layout`** — defer; arquitetura maior que o appetite do B75. +- **Mapa estático de títulos fora de `campaignHomeActions`** — defer até bundle do wizard medir custo do import Lucide na cadeia `wizardFlowTitleForSlug`. +- **Skip no desktop** — já adiado; link textual no conteúdo quando B63+ precisar. + Qualidade de decisão: 4/5 diff --git a/docs/roadmap.md b/docs/roadmap.md index 350cb464..ec51bccc 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -544,7 +544,7 @@ Paralelizáveis agora: **B73** (remover bottom nav — paralelo a B74; **desbloq ### Cortes seguros / não cortáveis -**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); ~~**B76** coerência do ajuste de votos~~ ✓ (entregue 2026-07-30); **B77** simplificar ajuste de votos (cortar = manter ritual pesado de três passos `?cenario=` que o CG pediu simplificar — emenda a B61); **B75** header mobile do wizard (cortar = Voltar+título no conteúdo competem com Mandate Red+sidebar no meio do ritual — emenda explícita a B59); **B73** remover bottom nav (paradigma intenção-primeiro); **B79–B90** bottom drawer de ações rápidas + busca fora do Início + catálogos por vertical (cortar = após B73 o mobile fica sem ação/busca nas páginas de trabalho — decisão de produto 2026-07-30: lote inteiro **não cortável**, execução rápida); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); ~~**B69** simplificar liderança~~ ✓ (entregue 2026-07-30 — `exclusive` default true; setor/`consentNote` dropados; **organização fica**: ramificação política, ex. SindMed); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). +**Não cortáveis:** Onda 0 (jurídico/Consent); ~~**B43–B48** chassis UX-1 + busca Municípios~~ ✓; ~~**B67** pan na strip de ações~~ ✓ (entregue 2026-07-29 — B58 escondia a barra; drag fine restaurado); ~~**B65** âncora inferior mobile~~ ✓ (entregue 2026-07-29); ~~**B66** focus limpa busca~~ ✓ (entregue 2026-07-30); **B59–B61** etapas mínimas do wizard de votos (chassis + município sticky + busca município + ajuste — cortar = ações do Início continuam inertes no ritual A1 **e** o CG perde o contexto de município no meio do passo); ~~**B76** coerência do ajuste de votos~~ ✓ (entregue 2026-07-30); **B77** simplificar ajuste de votos (cortar = manter ritual pesado de três passos `?cenario=` que o CG pediu simplificar — emenda a B61); ~~**B75** header mobile do wizard~~ ✓ (entregue 2026-07-30); **B73** remover bottom nav (paradigma intenção-primeiro); **B79–B90** bottom drawer de ações rápidas + busca fora do Início + catálogos por vertical (cortar = após B73 o mobile fica sem ação/busca nas páginas de trabalho — decisão de produto 2026-07-30: lote inteiro **não cortável**, execução rápida); ~~**B62** simplificar sinal~~ ✓ (entregue 2026-07-29); ~~**B69** simplificar liderança~~ ✓ (entregue 2026-07-30 — `exclusive` default true; setor/`consentNote` dropados; **organização fica**: ramificação política, ex. SindMed); ~~**E4R** seed da planilha~~ (entregue 2026-07-24 — ainda aplicar em produção após smoke); ~~**E8** conta da cadeira~~ (entregue 2026-07-24); ~~**C12** registro-fundação~~ (entregue 2026-07-24); C2 dados reais; assimetria declarado×estimado (relação de campo); ~~**E9** fila de alocação~~ (entregue 2026-07-24 — era o mínimo de "inteligência, não planilha"); ~~**B19** gerenciar assessores~~ (entregue 2026-07-24 — CG/candidato não entram em `/admin`); **B39** lembrar de mim / sessão longa (default de 2 h derruba sessão no onboarding/campo — atrito diário de adoção). **Cortes seguros** (se o prazo apertar, nesta ordem): ~~**B71**~~ ✓; **B74** gap strip v2 (B72 já apertou uma vez — cortar = strip um pouco esparça); **B78** pending navegação wizard (cortar = Voltar/seleção sem dim/shimmer até rede lenta reclamar — corpo pending pode shippar sem shimmer do B75); **B68** sugestões na busca aberta (digitação B48 continua; cortar = empty mudo no focus até folga); **B64** wizard tendência standalone (A1 ainda pode embutir tendência via B24 ✓ / passo curto; cortar = sem grid colorido + prefill até folga); **B63** wizard sinal standalone (A1 ainda pode embutir select pós-B62; cortar = sem grid/Drawer de tipos até folga); **B70** wizard liderança standalone (A1 ainda pode embutir passo curto pós-B69; cortar = sem grid 2/3/4 até folga — pacote Cairu sofre); **B57** delta 7d (Início continua com total+cobertura do B56; Δ era futuro de propósito); **B56** resumo no Início (Quadro já tem os mesmos agregados — cortar = Início sem briefing numérico); **B55** ações WA/share na busca (linha continua clicável); **B54** grid/cap (empilhar sempre); **B51**/**B53**/**B52**/**B50**/**B49** grupos secundários da busca (nessa ordem — Municípios B48 fica); **D5** inbox→rascunhos (manter registro manual + `wa.me`); **D4** envio bridge (manter `wa.me`); **D3** fundação do canal (atalho `wa.me` continua); **E12** camada TI (rollup manual por lista; **E17** já dá a leitura regional básica no Início); **E13** planejador de giros (rebaixado na fila de corte em 2026-07-24: "perna"/agenda é a restrição dominante nomeada em campo — cortar só depois de E12; agenda segue manual com J-A/J-B como guia); **E15** backtest (pós-eleição por definição — cortar = perder o aprendizado 2030); **E11** motor v1 (manter a fila E9 ✓ sem sugestões); **B13** símbolo proporcional (manter quantis/LQ como escala); ~~**E14** níveis~~ (entregue 2026-07-27 — `priority` continua, intocado); ~~**B8 F2** polígonos~~ (entregue 2026-07-26 — o mapa não é mais agregado em Salvador); **D2** push (adiar); **A6**; ~~**B14** município mais próximo~~ (entregue 2026-07-26); ~~**B38** sidebar recolhido em tablet~~ (entregue 2026-07-26); ~~**B40** login biométrico~~ (entregue 2026-07-28 — senha + B39 ✓ continuam, e cortar teria custado só o atalho de digital/Face ID; era o mais caro e o primeiro a cair desta leva B39–B42); **B42** polimento mobile da lista de municípios (Popover e card longos continuam usáveis — cortar custa conforto de teclado e densidade no celular); ~~**B41** scroll horizontal + sticky~~ (entregue 2026-07-27) (a tabela continua legível com zoom/pan da página — cortar custa o overflow em tablet; cai **depois** de B42 porque o bug de layout em mesa é mais caro que o polish mobile); ~~**B18** filtros salvos~~ ✓ entregue (lista + filtros manuais continuam; Visitados cobrem “voltar”); **B21** página dos Territórios de Identidade (o painel do E17 ✓ no Início continua entregando a leitura regional) — **B25** cai junto por definição (sem destino, sem link); ~~**B17** seletor de colunas~~ ✓ entregue (tabela completa continua; scroll horizontal); ~~**B22** explicação no header~~ (entregue 2026-07-26); ~~**B23** tooltip nas células~~ (entregue 2026-07-26); ~~**B24** auto-save + justificativa na Tendência~~ (entregue 2026-07-26); **B27** combobox + chips nos Assessores (a lista de checkboxes com "Salvar" continua atribuindo — cortar custa a mesma coerência do B24 mais o tempo do CG procurando nomes numa coluna durante o onboarding; corte junto com B24, já que dividem o endpoint); **B28** e-mail + celular na lista de lideranças (a ficha `/liderancas/[id]` continua mostrando o contato — cortar custa o gesto de copiar/abrir ZAP na varredura; quick win barato de cortar e de fazer); ~~**B30** convite de completar cadastro por WhatsApp na lista~~ (entregue 2026-07-26); **B31** coluna de dobradinhas nas lideranças (o `RelationMultiSelect` da ficha interna continua vinculando — cortar custa o preenchimento em lote do onboarding, uma ficha aberta por liderança, e mantém o vínculo invisível na varredura; corte junto com B27, já que dividem a peça de célula, e antes de B29 só se a mesa já tiver preenchido as dobradinhas à mão); ~~**B32** editar Status de apoio na célula~~ (entregue 2026-07-26); **B34** chips editáveis de municípios na lista de lideranças (o texto simples `row.municipalityNames.join(', ')` continua legível e a ficha `/liderancas/[id]` continua vinculando município via formulário — cortar custa o link direto ao município, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma peça de célula editável, e antes de B29 pelo mesmo motivo de coerência); **B37** chips editáveis de municípios na lista de dobradinhas (o número `municipalityCount` continua legível e a ficha de município continua vinculando dobradinha pelo `MunicipalityStrategyForm` — cortar custa o link direto, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha do município; corte junto com B34, já que dividem a mesma lib e a mesma decisão de container); ~~**B36** coluna de lideranças na lista de dobradinhas~~ (entregue 2026-07-26; a contagem `leadershipCount` era o fallback e a ficha `/liderancas/[id]` continua vinculando dobradinha via formulário — cortar custa o link direto à liderança, a navegação por chip e o atalho de adicionar/remover sem abrir a ficha; corte junto com B31, já que dividem a mesma mutação/schema/lock); ~~**B33** sort + filtro no header das dobradinhas~~ (entregue 2026-07-26); **B29** sort + filtro no header das lideranças (a busca por nome continua funcionando e o escopo do assessor já reduz a lista — cortar custa o recorte por status/setor/município no onboarding da rede e mantém o header rico como cópia única de municípios; por ser o item mais caro desta faixa (~1,5–2 dias com a extração do head), cai **antes** do B26, que é o que sustenta o gate de adoção); **B26** registrar sinal na célula (o formulário completo em `/[slug]?tab=updates` continua sendo o caminho — cortar custa o laço da fila do E9 ✓ e o registro em campo pelo celular, que é justamente onde o gate de adoção se decide; por isso cai **depois** de B22/B23/B24); ~~**C13** renomear Plano de Ação → Atividade~~ (entregue 2026-07-25); **B20** prioritários no Início (lista `?priority=alta` continua; só perde o atalho — mas a seção atual mente, então cortar = conviver com o bug); fill-in **renomear coluna Cobertura** (a lista continua legível com o rótulo longo — corte = header mais largo); fill-in **remover coluna Tipo** (a lista continua completa; só mantém densidade morta — corte = conviver com a coluna); ~~fill-in **remover lista de zonas do Início**~~ ✓ (entregue 2026-07-28 — o mapa B8 F2 ✓ continua abrindo cada ZE); ~~**B16** filtros no header~~ (entregue 2026-07-25); ~~**B15** sort por coluna~~ (entregue 2026-07-24); ~~**A11**/**E17**~~ (entregues 2026-07-24); ~~**E18** documentação de conceitos~~ (entregue 2026-07-24); ~~**E16** dossiê~~ (entregue 2026-07-25); demais débitos/fill-ins.