B59: chassis do wizard de campanha (shell, rotas /acoes, hrefs no Início).

Staff ganha CampaignWizardShell, rotas /campanha/acoes/<slug> com passo ponte até B60, e strip do Início com links nos fluxos wizard.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 22:01:32 -03:00
parent 7a3728a3d4
commit dd23d2a44a
16 changed files with 293 additions and 8 deletions

View file

@ -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/<slug>` (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).

View file

@ -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/<slug>` (`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).

View file

@ -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/<slug>` (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

View file

@ -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<br/>WA / share"]
B56n["B56 Resumo campanha<br/>total + cobertura ✓"]
B57n["B57 Delta 7d estimativa<br/>(futuro / baixa prio)"]
B59n["B59 Chassis wizard<br/>shell + município sticky"]
B59n["B59 Chassis wizard ✓<br/>shell + município sticky"]
B60n["B60 Busca município<br/>nos wizards"]
B61n["B61 Ajuste votos<br/>cenários + atalhos"]
B62n["B62 Simplificar<br/>modelo de sinal"]

View file

@ -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 <WizardMunicipalityPlaceholderStep />
}

View file

@ -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 <div className="mx-auto flex min-h-full w-full max-w-lg flex-col">{children}</div>
}

View file

@ -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<HTMLHeadingElement>(null)
const titleId = useId()
useEffect(() => {
titleRef.current?.focus()
}, [stepTitle])
return (
<div className="flex min-h-full w-full flex-col">
<header className="sticky top-0 z-10 border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80">
<div className="relative flex min-h-11 items-center justify-center gap-2 px-2 pb-1 pt-[max(0.25rem,env(safe-area-inset-top))]">
<div
className="absolute left-0 top-1/2 -translate-y-1/2 md:hidden"
data-slot="wizard-mobile-back"
>
<Button variant="ghost" size="sm" className="min-h-11 gap-1 px-2" asChild>
<Link href={previousHref}>
<ArrowLeft className="size-4 shrink-0" aria-hidden />
Voltar
</Link>
</Button>
</div>
{municipalityLabel ? (
<p
className="max-w-[min(100%,14rem)] truncate px-12 text-center text-sm text-muted-foreground sm:max-w-md"
aria-label={`Município em atualização: ${municipalityLabel}`}
>
{municipalityLabel}
</p>
) : null}
{trailingAction ? (
<div className="absolute right-0 top-1/2 flex -translate-y-1/2 items-center">
{trailingAction}
</div>
) : null}
</div>
</header>
<main
aria-labelledby={titleId}
className={cn(
'flex flex-1 flex-col gap-6 py-6 md:justify-start',
contentAlign === 'end' ? 'justify-end md:justify-start' : 'justify-start',
)}
>
<h1
ref={titleRef}
id={titleId}
tabIndex={-1}
className="text-xl font-semibold tracking-tight outline-none md:text-2xl"
>
{stepTitle}
</h1>
{children}
</main>
</div>
)
}

View file

@ -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 = () => (
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref={CAMPAIGN_HOME}>
<p className="text-sm text-muted-foreground">{WIZARD_MUNICIPALITY_STEP_PLACEHOLDER}</p>
</CampaignWizardShell>
)

View file

@ -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<CampaignWizardActionId, string> = {
'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]}`

View file

@ -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,

View file

@ -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

View file

@ -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.'

View file

@ -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())

View file

@ -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)
})
})

View file

@ -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', () => {

View file

@ -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(
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
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(
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
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(
<CampaignWizardShell stepTitle="Passo 1" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
rerender(
<CampaignWizardShell stepTitle="Passo 2" previousHref="/campanha">
Corpo
</CampaignWizardShell>,
)
expect(document.activeElement).toBe(screen.getByRole('heading', { level: 1, name: 'Passo 2' }))
})
it('omits municipality caption when label is absent', () => {
render(
<CampaignWizardShell stepTitle="Pergunta" previousHref="/campanha">
{null}
</CampaignWizardShell>,
)
expect(screen.queryByLabelText(/Município em atualização/)).toBeNull()
})
it('shows municipality caption with accessible name', () => {
render(
<CampaignWizardShell
stepTitle="Pergunta"
previousHref="/campanha/acoes/atualizar-votos"
municipalityLabel="Cairu"
>
{null}
</CampaignWizardShell>,
)
const caption = screen.getByLabelText('Município em atualização: Cairu')
expect(caption.textContent).toBe('Cairu')
})
})