From dd23d2a44ad762597f46c8fdf3097b4ee69bb3b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Wed, 29 Jul 2026 22:01:32 -0300 Subject: [PATCH] =?UTF-8?q?B59:=20chassis=20do=20wizard=20de=20campanha=20?= =?UTF-8?q?(shell,=20rotas=20/acoes,=20hrefs=20no=20In=C3=ADcio).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Staff ganha CampaignWizardShell, rotas /campanha/acoes/ com passo ponte até B60, e strip do Início com links nos fluxos wizard. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 2 + docs/plans/chassis-wizard-campanha.md | 4 +- docs/roadmap.md | 8 +- .../campanha/(app)/acoes/[slug]/page.tsx | 25 ++++++ .../campanha/(app)/acoes/layout.tsx | 9 ++ .../campaign/shared/CampaignWizardShell.tsx | 84 +++++++++++++++++++ .../WizardMunicipalityPlaceholderStep.tsx | 12 +++ src/lib/campaignActionRoutes.ts | 28 +++++++ src/lib/campaignHomeActions.ts | 3 + src/lib/campaignPaths.ts | 2 + src/lib/campaignWizardCopy.ts | 5 ++ tests/e2e/campaignHomeActions.e2e.spec.ts | 6 ++ tests/unit/campaignActionRoutes.unit.spec.ts | 29 +++++++ tests/unit/campaignHomeActions.unit.spec.ts | 6 +- tests/unit/campaignWizardShell.unit.spec.tsx | 77 +++++++++++++++++ 16 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx create mode 100644 src/app/(campaign)/campanha/(app)/acoes/layout.tsx create mode 100644 src/components/campaign/shared/CampaignWizardShell.tsx create mode 100644 src/components/campaign/shared/WizardMunicipalityPlaceholderStep.tsx create mode 100644 src/lib/campaignActionRoutes.ts create mode 100644 src/lib/campaignWizardCopy.ts create mode 100644 tests/unit/campaignActionRoutes.unit.spec.ts create mode 100644 tests/unit/campaignWizardShell.unit.spec.tsx diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 1eaa6073..32121c9e 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,6 +7,7 @@ alwaysApply: false ## Status +- **B59 entregue em código (2026-07-29):** chassis UX-1 dos wizards de ação — `CampaignWizardShell` (`shared/`: Voltar só mobile com rótulo visível, caption sticky central de município, `contentAlign`, foco no `h1` ao trocar passo); rotas staff `/campanha/acoes/` (cinco entradas espelhando o catálogo B45, passo ponte "Em qual município?" até **B60**); `campaignActionRoutes.ts` + hrefs em `toHomeActionButtonProps`; layout `acoes/` com gate `staff`. Sem migration/action/Consent. Unit `campaignActionRoutes`, `campaignWizardShell`, `campaignHomeActions` atualizado; e2e Início → `atualizar-votos`. Plano: [`chassis-wizard-campanha.md`](../../../docs/plans/chassis-wizard-campanha.md). Desbloqueia **B60** / **B61** / **B63** / **B64**. - **B56 entregue em código (2026-07-29):** resumo da campanha no Início staff — `loadCampaignHomeSummary` + `CampaignHomeSummary` (total central `staffVoteTotal` + cobertura E8 com `Progress`); `summarySlot` em `CampaignHomeLayout`/`CampaignHomeStaffChrome`, oculto no modo focado B47; sem saudação; leader fora. Loader em `campaignDashboardData.ts` (não módulo top-level novo). Unit `campaignHomeSummary.unit.spec.tsx`. Sem migration. Plano: [`resumo-campanha-inicio.md`](../../../docs/plans/resumo-campanha-inicio.md). Desbloqueia slot visual de **B57** (delta 7d, futuro). - **B67 entregue em código (2026-07-29):** pan na strip de ações do Início (UX-1) — `CampaignHomeActionStrip` com drag-to-scroll em `pointer-fine` (`HOME_ACTION_STRIP_DRAG_THRESHOLD_PX` 8, `cursor-grab`/`grabbing`); scrollbar oculta (B58) mantida; touch `pan-x` nativo; `onClickCapture` suprime link após arraste. Unit em `campaignHomeActionButton.unit.spec.tsx`. Sem migration. Plano: [`restaurar-pan-strip-acoes-inicio.md`](../../../docs/plans/restaurar-pan-strip-acoes-inicio.md). Fecha Adiado drag desktop de B44. - **B58 entregue em código (2026-07-29):** polimento visual da strip de ações do Início (UX-1) — rótulos curtos no catálogo (`Ajustar votos`, `Registrar sinal`, `Ver esquecidos`, …); removido o `h2` "O que você quer fazer?" (`aria-label` da strip = "Ações rápidas"); scrollbar horizontal oculta (pan nativo touch; drag fine em **B67** ✓); escala ~1,05 no círculo (`pointer-fine` hover / `pointer-coarse` `data-pressing`) + `active:opacity-90`; e2e B47 retarget para `[data-slot="home-actions"]`. Sem migration/action/URL. Plano: [`polimento-strip-acoes-inicio.md`](../../../docs/plans/polimento-strip-acoes-inicio.md). diff --git a/AGENTS.md b/AGENTS.md index 27eb958a..1961cc14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,8 @@ Backlog consolidado (bloqueadores, site, campanha, white-label): [`docs/roadmap. **Recently resolved (2026-07-25): Engineering Consolidation Pass 2** (tracker: [`docs/IMPROVE-CODE-QUALITY-PLAN.md`](docs/IMPROVE-CODE-QUALITY-PLAN.md) § Pass 2; architecture: [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); agent map: `.cursor/rules/codebase-map.mdc`). Highlights: **W1 sistema de listas** — colunas como dado em `CampaignTable` (seams B17/B18), URL state compartilhado (`campaignListUrl` + módulos por domínio; o god module `municipalityUi.ts` virou `municipalityListUrl`/`municipalityListFilters`/`municipalityLabels`/`municipalitySignal`), shells (`CampaignListFooter`, `CampaignListEmptyState`), 8 superfícies migradas (municípios, lideranças, organizações, dobradinhas, demandas, apoiadores, assessores, atividades-shells), B16+ absorvido (`useOptimistic`, hrefs por serializador canônico, facet por slugs, facets no mesmo `Promise.all`); **W2** — fronteiras: `slug`/`phone`/`wordStartFilter`/`voteEstimate` movidos para `lib/`, 21 loaders `server-only`, `campaignRoles.ts` client-safe (sidebar sem o barrel de access), `votePledgeViews` (contract) vs `votePledgeData` (loaders), ciclo supporter quebrado, `components/campaign` em 15 subpastas de domínio; decisão D1: sem `src/domains/`, sem ports-and-adapters; **W4a** — `electionInsights.ts` (941 linhas, 2 exports vivos) deletado; sobreviventes em `electionFormat.ts`/`voteTrend.ts`; E10 nasce limpo; **W4b** — knip `exports`/`types`/`enumMembers` em **ERROR** no CI (0 findings; kit ui sem peças mortas); **W4c** — tabs das páginas de detalhe extraídas (`MunicipalityDetailTabs`, `ActivityOverviewTab`) + factory `detailTabUi`; **W4d** — `runCampaignFormAction` fecha o C8 F4 (13 ações em 7 arquivos; exceções documentadas); **W4e** — tipos de fonte única; **W4f** — copy "Praça"→"Município" varrida (apoiadores desambiguado: cidade do contato = "Cidade"); **D3** — consent do WhatsApp por chave estável (`whatsapp-inscricao`, fail-closed). Suites: 46 unit / 48 int / 7 e2e. +**Recently resolved (2026-07-29):** **B59** Chassis do wizard de campanha — rotas staff em `/campanha/acoes/` (`campaignActionRoutes.ts`, `campaignActionEntryHref`), layout `acoes/layout.tsx` (gate staff, coluna `max-w-lg`), `CampaignWizardShell` (voltar mobile, caption de município sticky, `trailingAction`, foco no `h1` ao trocar passo), cinco páginas-ponte até B60 e strip do Início com `href` nos fluxos wizard. Sem migration. Plano: [`docs/plans/chassis-wizard-campanha.md`](docs/plans/chassis-wizard-campanha.md). + **Recently resolved (2026-07-29):** **B48** Busca global — resultados Municípios (+ TIs) no Início staff: `POST /campanha/home-search` (`campaignJsonMutationRoute` + `homeSearchBodySchema`) chama `searchHomeMunicipalities` — municípios via `loadMunicipalityScope` + `matchesAtWordStart` + readout A11; territórios no mesmo grupo via `loadTerritoryOverview` (contexto regional E17, não carteira do assessor). Client: `HomeSearchResultsContext` (fetch único, extensível B49+), `HomeSearchMunicipalityGroup`, empty “Nenhum resultado.”; `MunicipalityVotePositionReadout` extraído de `MunicipalityList`. Sem migration. Plano: [`docs/plans/busca-global-resultados-municipios.md`](docs/plans/busca-global-resultados-municipios.md). **Recently resolved (2026-07-28):** **E11** Motor de sugestões v1 — the 8 highest-confidence data→decision patterns of the research report §6.1 (P1 reduto dormente/ameaçado, P2 ataque, P3 correligionário na frente, P5 expansão, P6 grande sem rede, P7 pledges estagnados, K-A não responde, K-B perdida vestida) now run as a human-in-the-loop decision queue: a "Sugestões" panel on the staff dashboard (top-5 by triage + "pauta do silêncio") and a card on the municipality Overview, with aceitar/adiar/descartar writing `allocationDecision`. Three layers: **`src/lib/suggestionCatalog.ts`** (client-safe — pure predicates over `MunicipalityTriggerInput`, a numbers-only input type, plus the exact card copy: probable reading, the "descartes" that become the dismiss form's quick picks, cheap-before-expensive menu, contraindication, statute; anchors are illustrative and private, calibration = E15); **`src/utilities/municipalityTriggers.ts`** (`server-only`, on the E13 planner's mold — own municipality read because the shared scope select lacks `engagementLevel`/`lastUpdateAt`, six reads in one `Promise.all`, and per-year LQ using E10's exact formula rather than a third LQ writing); **`resolveSuggestionRecord`** which RE-EVALUATES the pattern server-side before writing — the snapshot records decision-time factors, never render-time ones, and a pattern that left the queue refuses with the safe `SUGGESTION_STALE_MESSAGE`; advisory lock `municipality-suggestion:{id}:{patternId}` is taken before re-reading the latest decision. **The audit killed the plan's "sem migration":** the real outcome enum was `aceita|descarta|movimento` (the plan's `descartada|adiada` values never existed), so "Adiar" cost migration `20260728_041547_add_allocation_decision_adiada_outcome` (E14's `movimento` precedent), with `postponeDays` derived from the recomputed triage (7d; 14d on levels 4–5) and `suppressUntil` in the snapshot instead of a column. Suppression is pure and unit-pinned (`isSuggestionSuppressedByDecision`): aceita 14d, descarta 30d, adiada until snooze, malformed snapshot falls back to the shortest postpone — and **"nível 1 fura a fila"**: a trigger returning as confirmed risk pierces a suppression recorded at lower urgency. Structural differential is code (§6.3): P2 yields to P3/K-B, P7 yields to K-A when effort was recorded. P3 fires from the artifact alone (`runningAgain2026` is 100% `desconhecido` with no read path — "concorre de novo?" is the menu's first item, not a trigger). Silence review = alta/N2+ with no FIRED pattern (a suppressed one was answered, not silence) and no signal ≥30d; includes `priority: alta` because E14 shipped without backfill. UI: explicit submit on all three outcomes (the `campanha-edit-where-you-see` exception for note/confirmation flows), native radios (`accent-primary` — first radio precedent in the repo), managed focus across the trigger↔form swap, level 1 is the only red. **Two facts worth keeping:** the public-site base `ul { my-6 ml-6 list-disc }` leaks into campaign surfaces (the factors list needed B20's reset), and artifact-anchored patterns cannot be staged from DB rows — int tests that need an artifact property claim municípios through the fixture allocator until one qualifies (measured: 109/435 top-quartile, 205/435 quiet, and reduto+coreBlock is only 13/435, which moved P1's trigger coverage to the 45-case unit spec; explicit slugs would race the allocator's wrapping sequence). E18 concepts `triagem-de-sugestoes` + `pauta-do-silencio`; `/campanha` First Load JS 268 → 277 kB. Adjacent follow-ups live in the plan: wiring E13's "não vá" override to `resolveSuggestion`; catalog fase 2 (T/I/J, P4/P8–P12, K-C) after 2 weeks of real use + 20 decisions; a dedicated route if >15 recurring active suggestions. Plano: [`docs/plans/motor-de-sugestoes.md`](docs/plans/motor-de-sugestoes.md). diff --git a/docs/plans/chassis-wizard-campanha.md b/docs/plans/chassis-wizard-campanha.md index 87032fd0..a8f05ce0 100644 --- a/docs/plans/chassis-wizard-campanha.md +++ b/docs/plans/chassis-wizard-campanha.md @@ -1,7 +1,7 @@ # Chassis do wizard de campanha (shell + voltar) -Status: rascunho -Atualizado em: 2026-07-29 — absorve lembrete de produto: município em atualização sempre visível no topo (centro, semi-discreto) +Status: entregue (2026-07-29) +Atualizado em: 2026-07-29 — as-built: `CampaignWizardShell` (`shared/`), rotas `/campanha/acoes/` (cinco entradas staff com passo ponte “Em qual município?”), `campaignActionRoutes.ts` + hrefs no catálogo B45; caption sticky centro; Voltar só mobile; sem migration. Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B59 — UX-1 wizards) Impeccable: C — UI nova (shell de fluxo multi-passo sob `/campanha`) Appetite: ~0,75–1 dia eng; layout de passo + header mobile + slot de conteúdo + caption sticky do município; sem migration diff --git a/docs/roadmap.md b/docs/roadmap.md index acceb79e..2fb091fb 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # Roadmap — Teqo -Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; ~~**B67**~~ ✓ pan na strip; **B68** sugestões na busca aberta; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B63. +Atualizado em: 2026-07-29 — ~~**B56**~~ ✓ resumo no Início; ~~**B67**~~ ✓ pan na strip; **B68** sugestões na busca aberta; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B63. 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,13 +83,13 @@ 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**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** **B59** → **B60**/**B61**/**B64** · **B62** → **B63**. +- **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**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**B64** · **B62** → **B63**. - ~~**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) - **B58** Polimento visual da strip de ações do Início — ~~remover heading "O que você quer fazer?"; rótulos curtos (Ajustar votos, Registrar sinal, …; atalho → **"Ver esquecidos"** _(proposto — validar com produto)_); scale discreto no círculo (hover/long-press) + opacity no active; scrollbar horizontal oculta (pan preservado)~~ — **entregue 2026-07-29** (catálogo + strip + botão; e2e atualizado) · Depende de B45 ✓ · ~0,5d · Janela 1 · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md) · **regressão de pan → B67** - **B67** Restaurar pan/scroll na strip de ações — **entregue 2026-07-29** (scrollbar oculta mantida; `pointer-fine` click+arrasta + touch pan nativo; limiar 8px; unit drag vs click) · Depende de B44 ✓ / B58 ✓ · ~0,25–0,5d · Janela 1 · Impeccable B · [plano](plans/restaurar-pan-strip-acoes-inicio.md) -- **B59** Chassis do wizard (shell de passo + seta Voltar só mobile; desktop = browser; **município sticky** no limite superior central, semi-discreto, após escolha) · Depende de B45 ✓ · ~0,75–1d · Janela 1 · Impeccable C · [plano](plans/chassis-wizard-campanha.md) +- **B59** ~~Chassis do wizard~~ — **entregue 2026-07-29** (`CampaignWizardShell`; `/campanha/acoes/*` com layout staff-only; Voltar `md:hidden`; caption sticky de município; catálogo B45 com hrefs; passo ponte até B60) · [plano](plans/chassis-wizard-campanha.md) - **B60** Etapa busca município nos wizards (UX da busca do Início, só municípios; select → avança) · Depende de B59 · soft B48 · ~1d · Janela 1 · Impeccable B · [plano](plans/busca-municipio-wizard.md) - **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · Depende de B59 · soft B60 · ~1–1,25d · Janela 1 · Impeccable C · [plano](plans/ajuste-votos-wizard.md) - **B62** Simplificar modelo de sinal (dropar Fonte/Triangulado; labels mesa sem “broker”; migration + E11) · Depende de C12 ✓ · ~0,75–1d · Janela 1 · Impeccable B · [plano](plans/simplificar-modelo-sinal.md) @@ -230,7 +230,7 @@ flowchart TD B55n["B55 Busca ações
WA / share"] B56n["B56 Resumo campanha
total + cobertura ✓"] B57n["B57 Delta 7d estimativa
(futuro / baixa prio)"] - B59n["B59 Chassis wizard
shell + município sticky"] + B59n["B59 Chassis wizard ✓
shell + município sticky"] B60n["B60 Busca município
nos wizards"] B61n["B61 Ajuste votos
cenários + atalhos"] B62n["B62 Simplificar
modelo de sinal"] diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx new file mode 100644 index 00000000..c106ffe9 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx @@ -0,0 +1,25 @@ +import { notFound } from 'next/navigation' + +import { WizardMunicipalityPlaceholderStep } from '@/components/campaign/shared/WizardMunicipalityPlaceholderStep' +import { + CAMPAIGN_WIZARD_ACTION_SLUGS, + isCampaignWizardActionSlug, +} from '@/lib/campaignActionRoutes' + +type CampaignActionWizardPageProps = { + params: Promise<{ slug: string }> +} + +export function generateStaticParams() { + return Object.values(CAMPAIGN_WIZARD_ACTION_SLUGS).map((slug) => ({ slug })) +} + +export default async function CampaignActionWizardPage({ params }: CampaignActionWizardPageProps) { + const { slug } = await params + + if (!isCampaignWizardActionSlug(slug)) { + notFound() + } + + return +} diff --git a/src/app/(campaign)/campanha/(app)/acoes/layout.tsx b/src/app/(campaign)/campanha/(app)/acoes/layout.tsx new file mode 100644 index 00000000..bd18bf09 --- /dev/null +++ b/src/app/(campaign)/campanha/(app)/acoes/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react' + +import { requireCampaignPageActor } from '@/utilities/campaignPageActor' + +export default async function CampaignActionsLayout({ children }: { children: ReactNode }) { + await requireCampaignPageActor({ gate: 'staff' }) + + return
{children}
+} diff --git a/src/components/campaign/shared/CampaignWizardShell.tsx b/src/components/campaign/shared/CampaignWizardShell.tsx new file mode 100644 index 00000000..5dda2993 --- /dev/null +++ b/src/components/campaign/shared/CampaignWizardShell.tsx @@ -0,0 +1,84 @@ +'use client' + +import { ArrowLeft } from 'lucide-react' +import Link from 'next/link' +import { useEffect, useId, useRef, type ReactNode } from 'react' + +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' + +export type CampaignWizardShellProps = { + stepTitle: string + previousHref: string + municipalityLabel?: string + trailingAction?: ReactNode + contentAlign?: 'start' | 'end' + children: ReactNode +} + +export const CampaignWizardShell = ({ + stepTitle, + previousHref, + municipalityLabel, + trailingAction, + contentAlign = 'start', + children, +}: CampaignWizardShellProps) => { + const titleRef = useRef(null) + const titleId = useId() + + useEffect(() => { + titleRef.current?.focus() + }, [stepTitle]) + + return ( +
+
+
+
+ +
+ {municipalityLabel ? ( +

+ {municipalityLabel} +

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

+ {stepTitle} +

+ {children} +
+
+ ) +} diff --git a/src/components/campaign/shared/WizardMunicipalityPlaceholderStep.tsx b/src/components/campaign/shared/WizardMunicipalityPlaceholderStep.tsx new file mode 100644 index 00000000..daa657f6 --- /dev/null +++ b/src/components/campaign/shared/WizardMunicipalityPlaceholderStep.tsx @@ -0,0 +1,12 @@ +import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' +import { CAMPAIGN_HOME } from '@/lib/campaignPaths' +import { + WIZARD_MUNICIPALITY_STEP_PLACEHOLDER, + WIZARD_MUNICIPALITY_STEP_TITLE, +} from '@/lib/campaignWizardCopy' + +export const WizardMunicipalityPlaceholderStep = () => ( + +

{WIZARD_MUNICIPALITY_STEP_PLACEHOLDER}

+
+) diff --git a/src/lib/campaignActionRoutes.ts b/src/lib/campaignActionRoutes.ts new file mode 100644 index 00000000..23635a55 --- /dev/null +++ b/src/lib/campaignActionRoutes.ts @@ -0,0 +1,28 @@ +/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */ + +export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const + +export type CampaignWizardActionId = + | 'update-votes' + | 'register-signal' + | 'change-trend' + | 'update-leadership' + | 'register-demand' + +export const CAMPAIGN_WIZARD_ACTION_SLUGS: Record = { + 'update-votes': 'atualizar-votos', + 'register-signal': 'registrar-sinal', + 'change-trend': 'mudar-tendencia', + 'update-leadership': 'atualizar-lideranca', + 'register-demand': 'registrar-pedido', +} + +const wizardActionSlugs = new Set(Object.values(CAMPAIGN_WIZARD_ACTION_SLUGS)) + +export const isCampaignWizardActionSlug = (slug: string): boolean => wizardActionSlugs.has(slug) + +export const isCampaignWizardActionId = (id: string): id is CampaignWizardActionId => + id in CAMPAIGN_WIZARD_ACTION_SLUGS + +export const campaignActionEntryHref = (id: CampaignWizardActionId): string => + `${CAMPAIGN_ACTIONS_HOME}/${CAMPAIGN_WIZARD_ACTION_SLUGS[id]}` diff --git a/src/lib/campaignHomeActions.ts b/src/lib/campaignHomeActions.ts index 17b6b5fb..67287d0b 100644 --- a/src/lib/campaignHomeActions.ts +++ b/src/lib/campaignHomeActions.ts @@ -10,6 +10,7 @@ import { Users, } from 'lucide-react' +import { campaignActionEntryHref, isCampaignWizardActionId } from '@/lib/campaignActionRoutes' import { LEADER_CONTACTS_HOME } from '@/lib/campaignPaths' import type { CampaignRole } from '@/lib/campaignRoles' import { isStaffCampaignRole } from '@/lib/campaignRoles' @@ -124,6 +125,8 @@ export const toHomeActionButtonProps = ( href = LEADER_CONTACTS_HOME } else if (action.id === 'uncovered-municipalities') { href = uncoveredMunicipalitiesHref + } else if (isCampaignWizardActionId(action.id)) { + href = campaignActionEntryHref(action.id) } return { ...action, diff --git a/src/lib/campaignPaths.ts b/src/lib/campaignPaths.ts index 3cc316aa..cec8b080 100644 --- a/src/lib/campaignPaths.ts +++ b/src/lib/campaignPaths.ts @@ -1,2 +1,4 @@ /** Client-safe `/campanha` route paths shared by nav, gates, and home actions. */ +export const CAMPAIGN_HOME = '/campanha' as const + export const LEADER_CONTACTS_HOME = '/campanha/contatos' as const diff --git a/src/lib/campaignWizardCopy.ts b/src/lib/campaignWizardCopy.ts new file mode 100644 index 00000000..4d3a7575 --- /dev/null +++ b/src/lib/campaignWizardCopy.ts @@ -0,0 +1,5 @@ +/** Shared copy for UX-1 wizard steps until B60 ships municipality search. */ +export const WIZARD_MUNICIPALITY_STEP_TITLE = 'Em qual município?' as const + +export const WIZARD_MUNICIPALITY_STEP_PLACEHOLDER = + 'A busca por nome do município será feita nesta tela em breve.' diff --git a/tests/e2e/campaignHomeActions.e2e.spec.ts b/tests/e2e/campaignHomeActions.e2e.spec.ts index b9fdd171..d095e2f2 100644 --- a/tests/e2e/campaignHomeActions.e2e.spec.ts +++ b/tests/e2e/campaignHomeActions.e2e.spec.ts @@ -1,3 +1,4 @@ +import { WIZARD_MUNICIPALITY_STEP_TITLE } from '../../src/lib/campaignWizardCopy.js' import { expect, test } from './fixtures/campaignE2EFixtures.js' const staffActionLabels = [ @@ -89,6 +90,11 @@ test.describe('Início — catálogo de ações (B45)', () => { await expect(link.or(button)).toBeVisible() } + await page.getByRole('link', { name: 'Ajustar votos', exact: true }).click() + await page.waitForURL(/\/campanha\/acoes\/atualizar-votos/) + await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible() + + await page.goto('/campanha') await page.getByRole('link', { name: 'Ver esquecidos', exact: true }).click() await page.waitForURL(/\/campanha\/municipios/) const url = new URL(page.url()) diff --git a/tests/unit/campaignActionRoutes.unit.spec.ts b/tests/unit/campaignActionRoutes.unit.spec.ts new file mode 100644 index 00000000..c2f87571 --- /dev/null +++ b/tests/unit/campaignActionRoutes.unit.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { + CAMPAIGN_ACTIONS_HOME, + CAMPAIGN_WIZARD_ACTION_SLUGS, + campaignActionEntryHref, + isCampaignWizardActionId, + isCampaignWizardActionSlug, +} from '@/lib/campaignActionRoutes' + +describe('campaignActionRoutes', () => { + it.each( + Object.entries(CAMPAIGN_WIZARD_ACTION_SLUGS) as Array< + [keyof typeof CAMPAIGN_WIZARD_ACTION_SLUGS, string] + >, + )('maps %s to /campanha/acoes/%s', (id, slug) => { + expect(campaignActionEntryHref(id)).toBe(`${CAMPAIGN_ACTIONS_HOME}/${slug}`) + expect(isCampaignWizardActionSlug(slug)).toBe(true) + }) + + it('recognizes wizard ids and rejects list shortcuts', () => { + expect(isCampaignWizardActionId('update-votes')).toBe(true) + expect(isCampaignWizardActionId('uncovered-municipalities')).toBe(false) + }) + + it('rejects unknown wizard slugs', () => { + expect(isCampaignWizardActionSlug('inventado')).toBe(false) + }) +}) diff --git a/tests/unit/campaignHomeActions.unit.spec.ts b/tests/unit/campaignHomeActions.unit.spec.ts index 334c1870..627b1bb1 100644 --- a/tests/unit/campaignHomeActions.unit.spec.ts +++ b/tests/unit/campaignHomeActions.unit.spec.ts @@ -52,8 +52,10 @@ describe('toHomeActionButtonProps', () => { const props = toHomeActionButtonProps(homeActionsForRole('coordinator'), href) const uncovered = props.find((action) => action.id === 'uncovered-municipalities') expect(uncovered?.href).toBe(href) - const inert = props.find((action) => action.id === 'update-votes') - expect(inert?.href).toBeUndefined() + const updateVotes = props.find((action) => action.id === 'update-votes') + expect(updateVotes?.href).toBe('/campanha/acoes/atualizar-votos') + const registerSignal = props.find((action) => action.id === 'register-signal') + expect(registerSignal?.href).toBe('/campanha/acoes/registrar-sinal') }) it('omits uncovered href when not provided', () => { diff --git a/tests/unit/campaignWizardShell.unit.spec.tsx b/tests/unit/campaignWizardShell.unit.spec.tsx new file mode 100644 index 00000000..81f8b42b --- /dev/null +++ b/tests/unit/campaignWizardShell.unit.spec.tsx @@ -0,0 +1,77 @@ +import { cleanup, render, screen, within } from '@testing-library/react' +import { afterEach, describe, expect, it } from 'vitest' + +import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell' +import { WIZARD_MUNICIPALITY_STEP_TITLE } from '@/lib/campaignWizardCopy' + +describe('CampaignWizardShell', () => { + afterEach(() => { + cleanup() + }) + + it('renders mobile back link with visible Voltar label', () => { + const { container } = render( + + Corpo + , + ) + + 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('labels main landmark with the step title', () => { + const { container } = render( + + Corpo + , + ) + + const main = within(container).getByRole('main') + const heading = within(container).getByRole('heading', { level: 1, name: 'Pergunta' }) + expect(main.getAttribute('aria-labelledby')).toBe(heading.id) + }) + + it('moves focus to the step title when stepTitle changes', () => { + const { rerender } = render( + + Corpo + , + ) + + rerender( + + Corpo + , + ) + + expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' })) + }) + + it('omits municipality caption when label is absent', () => { + render( + + {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') + }) +})