B63: wizard registrar sinal (grid de tipos + passo de texto).

Two-step UX-1 flow on the B59 chassis with URL contract ?signalType= and optional ?entryAction= for embedded skip; reuses createMunicipalityListSignalFormAction.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-30 03:30:11 -03:00
parent fbce90069c
commit 09fa21d4b3
13 changed files with 562 additions and 6 deletions

View file

@ -8,6 +8,7 @@ alwaysApply: false
## Status
- **B70 entregue em código (2026-07-30):** wizard atualizar liderança (A4 standalone) — `/campanha/acoes/atualizar-lideranca?municipio=` com grid 2/3/4 (`WizardLeadershipStep`), form curto de 6 campos (`WizardLeadershipForm`), skip via `trailingAction` quando `entryAction !== 'update-leadership'`, Continuar após Salvar dirty → Início + toast; `loadWizardLeadershipTiles`; writes `updateLeadershipWizard`/`createLeadershipWizard` (Contact+Leadership em transação); `WIZARD_ENTRY_ACTION_QUERY_KEY` em `campaignActionRoutes.ts`; `truncateNameAtWordBoundary`. Sem migration. Plano: [`wizard-atualizar-lideranca.md`](../../../docs/plans/wizard-atualizar-lideranca.md).
- **B63 entregue em código (2026-07-30):** wizard registrar sinal (UX-1) — duas etapas no chassis B59: `WizardSignalTypeStep` (grid 2×3 de tiles transparentes + borda, ícone Lucide, Drawer bottom com `infoContent` provisório, `contentAlign="end"` no mobile) e `WizardSignalBodyStep` (textarea + Salvar explícito via `createMunicipalityListSignalFormAction` → `createMunicipalityUpdate` `kind: 'sinal'`). Contrato URL: `?municipio=` (B60) → `?signalType=` (tipo) → body; `?entry=` opcional — skip "Pular registro de sinal" visível só quando embutido (`entry` presente e ≠ `register-signal`), href skip → `/campanha` na v1. Helpers: `wizardSignalHref`, `resolveWizardSignalTypeParam`, `parseWizardEntryActionParam` em `campaignActionRoutes.ts`; `municipalitySignalTypeMeta.ts`; `wizardSignalUi.ts`. B63+ alinha `flowTitle`/`skip` ao chrome B75. Sem migration/Consent. Unit `campaignActionRoutes` + `wizardSignalUi`; e2e standalone + info não avança + skip embutido. Plano: [`wizard-registro-sinal.md`](../../../docs/plans/wizard-registro-sinal.md). Desbloqueia prefill de **B64** (soft).
- **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 `<main>` (`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 entregue em código (2026-07-30):** wizard ajuste de votos numa tela — `WizardExpectedVotesStep` substitui o ritual `?cenario=` de B61/B76 por editor único (strip + 3 inputs compact + atalhos no cenário focado); coerência `pessimistic ≤ central ≤ optimistic` só no confirm (`getWizardVoteViolation` + `getWizardVoteViolationHighlights`); `?cenario=` legado → redirect canônico; órfãos `wizardVoteStepTitle`/`mergeWizardVoteEstimate`/etc. removidos. Unit `wizardExpectedVotesStep`/`wizardVoteEstimate`; e2e B61 atualizado. Sem migration. Plano: [`simplificar-ajuste-votos-wizard.md`](../../../docs/plans/simplificar-ajuste-votos-wizard.md). Desbloqueia wiring A1 com menos atrito antes de **B63**/**B64**.

View file

@ -1,12 +1,30 @@
# Wizard registrar sinal — escolher tipo + detalhar texto
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-30)
Atualizado em: 2026-07-30
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B63 — UX-1 wizards)
Impeccable: C — UI nova (grid de tipos + drawer de info + passo de texto)
Appetite: ~1–1,25 dia eng; 2 etapas no B59; write via action `createMunicipalityUpdate` existente
Responsável: —
## Contrato de URL (as-built)
```text
/campanha/acoes/registrar-sinal
→ B60 WizardMunicipalitySearchStep
/campanha/acoes/registrar-sinal?municipio=<slug>
→ WizardSignalTypeStep (grid)
/campanha/acoes/registrar-sinal?municipio=<slug>&signalType=<enum>
→ WizardSignalBodyStep (textarea + Salvar)
Query opcional (propagar entre passos):
?entryAction=register-signal|update-votes|…
```
Helpers em `src/lib/campaignActionRoutes.ts`: `WIZARD_SIGNAL_TYPE_QUERY_KEY`, `WIZARD_ENTRY_ACTION_QUERY_KEY`, `parseWizardEntryActionParam`, `resolveWizardSignalTypeParam`, `wizardSignalHref`. Skip: `shouldShowWizardSignalSkip` em `wizardSignalUi.ts` — oculto quando `entryAction` ausente ou `register-signal`; visível caso contrário (href → `/campanha` na v1).
## Design (Impeccable)
Âncoras: `PRODUCT.md` / `DESIGN.md` · `Drawer` shadcn · tema `campaign` · labels de **B62**.

View file

@ -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; ~~**B70**~~ ✓ wizard A4 liderança; **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/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; ~~**B70**~~ ✓ wizard A4 liderança; **B74** gap strip v2; ~~**B75**~~ ✓ header mobile wizard; **B73** remover bottom nav; ~~**B63**~~ ✓ wizard registrar sinal; ~~B76/B66/B71/B69/B62/B56/B67/B68/B59/B65/B60/B48–B52/B72~~ ✓; B53–B55; wizards ~~B63~~ ✓/B64/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**~~ ✓ → **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**~~ ✓ → **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)
@ -95,7 +95,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **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~~ — **entregue 2026-07-30** (`WizardExpectedVotesStep` reescrito: três cenários numa tela com `VoteEstimateScenarioInputs` compact + strip + atalhos no foco; validação só no confirm com `Alert` + highlight; POST batch inalterado; `?cenario=` legado redireciona para URL canônica; órfãos do ritual B61 removidos de `wizardVoteEstimate`/`campaignActionRoutes`) · Depende de B61 ✓ / B76 ✓ · soft ~~B75~~ ✓ · ~0,75–1d · Janela 1 · 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)
- ~~**B63** Wizard registrar sinal (grid de tipos + Drawer info + texto + “Pular” se origem ≠ register-signal)~~ — **entregue 2026-07-30** (`WizardSignalTypeStep` + `WizardSignalBodyStep`; contrato `?signalType=` + `?entryAction=`; `municipalitySignalTypeMeta` + `wizardSignalUi`; write via `createMunicipalityListSignalFormAction`; standalone → Início + toast; sem migration) · 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)
- ~~**B69** Simplificar modelo de liderança (`exclusive` default true; dropar setor/`consentNote`; **org fica** — ramificação política, decisão 2026-07-30)~~ ✓ (entregue 2026-07-30) · Depende de — · Janela 1 · [plano](plans/simplificar-modelo-lideranca.md)
- ~~**B70** Wizard atualizar liderança (grid 2/3/4 + form curto + skip + Continuar dirty + focus)~~ — **entregue 2026-07-30** (`WizardLeadershipStep` + `WizardLeadershipForm`; `loadWizardLeadershipTiles`; `updateLeadershipWizard`/`createLeadershipWizard` com Contact+Leadership; `entryAction` em `campaignActionRoutes`; e2e smoke) · Depende de B59 + B69 ✓ · soft B60 ✓ · ~1,25–1,5d · Janela 1 · Impeccable C · [plano](plans/wizard-atualizar-lideranca.md)

View file

@ -1,6 +1,8 @@
import { notFound, redirect } from 'next/navigation'
import { WizardLeadershipStep } from '@/components/campaign/leadership/WizardLeadershipStep'
import { WizardSignalBodyStep } from '@/components/campaign/municipality/WizardSignalBodyStep'
import { WizardSignalTypeStep } from '@/components/campaign/municipality/WizardSignalTypeStep'
import { WizardExpectedVotesStep } from '@/components/campaign/shared/WizardExpectedVotesStep'
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
@ -10,10 +12,13 @@ import {
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
WIZARD_ENTRY_ACTION_QUERY_KEY,
WIZARD_MUNICIPIO_QUERY_KEY,
WIZARD_SCENARIO_QUERY_KEY,
WIZARD_SIGNAL_TYPE_QUERY_KEY,
wizardActionHref,
wizardSignalHref,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import { toVoteEstimateScenarioViewModel } from '@/lib/voteEstimate'
@ -94,5 +99,40 @@ export default async function CampaignActionWizardPage({
)
}
if (slug === CAMPAIGN_WIZARD_ACTION_SLUGS['register-signal']) {
const entryAction = parseWizardEntryActionParam(
resolvedSearchParams[WIZARD_ENTRY_ACTION_QUERY_KEY],
)
const { signalType, invalid } = resolveWizardSignalTypeParam(
resolvedSearchParams[WIZARD_SIGNAL_TYPE_QUERY_KEY],
)
if (invalid) {
redirect(wizardSignalHref(slug, municipalitySlug, undefined, entryAction))
}
if (signalType) {
return (
<WizardSignalBodyStep
actionSlug={slug}
municipalityId={municipality.id}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
signalType={signalType}
entryAction={entryAction}
/>
)
}
return (
<WizardSignalTypeStep
actionSlug={slug}
municipalityName={municipality.name}
municipalitySlug={municipality.slug}
entryAction={entryAction}
/>
)
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
}

View file

@ -19,6 +19,7 @@ import {
parsePoliticalTrendStatusFormValue,
} from '@/lib/schemas/municipality'
import { parseMunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { WIZARD_SIGNAL_SAVED_MESSAGE } from '@/lib/wizardSignalUi'
import {
runCampaignFormAction,
type CampaignFormActionState,
@ -100,7 +101,7 @@ export const createMunicipalityListSignalFormAction = async (
signalType: parseMunicipalitySignalType(optionalFormText(formData, 'signalType')),
})
revalidateMunicipalityListPaths({ slug: optionalMunicipalitySlugFromForm(formData) })
return { message: 'Sinal registrado.' }
return { message: WIZARD_SIGNAL_SAVED_MESSAGE }
},
genericMessage: 'Não foi possível registrar o sinal. Verifique seu acesso e tente novamente.',
})

View file

@ -0,0 +1,116 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useActionState } from 'react'
import { createMunicipalityListSignalFormAction } from '@/app/(campaign)/campanha/(app)/municipios/municipalityStaffFormActions'
import { CampaignFormActionMessage } from '@/components/campaign/shared/CampaignFormActionMessage'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useCampaignFormSuccessToast } from '@/components/campaign/shared/useCampaignFormSuccessToast'
import { Button } from '@/components/ui/button'
import { Spinner } from '@/components/ui/Spinner'
import { Textarea } from '@/components/ui/textarea'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardSignalHref } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { municipalitySignalTypeLabels } from '@/lib/schemas/municipalityUpdate'
import {
shouldShowWizardSignalSkip,
WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX,
WIZARD_SIGNAL_SAVE_LABEL,
WIZARD_SIGNAL_SKIP_LABEL,
wizardSignalSkipHref,
} from '@/lib/wizardSignalUi'
type WizardSignalBodyStepProps = {
actionSlug: string
municipalityId: number
municipalityName: string
municipalitySlug: string
signalType: MunicipalitySignalType
entryAction?: CampaignWizardActionId
}
export const WizardSignalBodyStep = ({
actionSlug,
municipalityId,
municipalityName,
municipalitySlug,
signalType,
entryAction,
}: WizardSignalBodyStepProps) => {
const router = useRouter()
const [state, submitAction, isPending] = useActionState(
createMunicipalityListSignalFormAction,
{},
)
const showSkip = shouldShowWizardSignalSkip(entryAction)
const stepTitle = `${WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX}: ${municipalitySignalTypeLabels[signalType]}`
useCampaignFormSuccessToast(state, () => {
router.push(CAMPAIGN_HOME)
})
return (
<CampaignWizardShell
stepTitle={stepTitle}
previousHref={wizardSignalHref(actionSlug, municipalitySlug, undefined, entryAction)}
municipalityLabel={municipalityName}
trailingAction={
showSkip ? (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<Link href={wizardSignalSkipHref()}>{WIZARD_SIGNAL_SKIP_LABEL}</Link>
</Button>
) : undefined
}
>
<form
action={submitAction}
className="flex flex-col gap-6"
aria-busy={isPending || undefined}
data-pending={isPending ? '' : undefined}
>
<input type="hidden" name="municipalityId" value={municipalityId} />
<input type="hidden" name="municipalitySlug" value={municipalitySlug} />
<input type="hidden" name="signalType" value={signalType} />
<div className="flex flex-col gap-2">
<label htmlFor="wizard-signal-body" className="text-sm font-medium">
O que aconteceu?
</label>
<Textarea
id="wizard-signal-body"
name="body"
rows={5}
maxLength={5000}
required
disabled={isPending}
className="min-h-28"
placeholder="Descreva o fato com o contexto que a equipe precisa lembrar depois."
/>
</div>
{state.status !== 'success' ? <CampaignFormActionMessage state={state} /> : null}
<div className="flex justify-end">
<Button type="submit" disabled={isPending} className="min-h-11 min-w-[7rem]">
{isPending ? (
<>
<Spinner data-icon="inline-start" aria-hidden="true" />
Salvando…
</>
) : (
WIZARD_SIGNAL_SAVE_LABEL
)}
</Button>
</div>
<div aria-live="polite" className="sr-only">
{isPending ? 'Salvando sinal.' : null}
</div>
</form>
</CampaignWizardShell>
)
}

View file

@ -0,0 +1,129 @@
'use client'
import { Info } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { Button } from '@/components/ui/button'
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/Drawer'
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { wizardActionHref, wizardSignalHref } from '@/lib/campaignActionRoutes'
import { municipalitySignalTypeMeta } from '@/lib/municipalitySignalTypeMeta'
import type { MunicipalitySignalType } from '@/lib/schemas/municipalityUpdate'
import { cn } from '@/lib/utils'
import {
shouldShowWizardSignalSkip,
WIZARD_SIGNAL_SKIP_LABEL,
WIZARD_SIGNAL_TYPE_STEP_TITLE,
wizardSignalSkipHref,
} from '@/lib/wizardSignalUi'
type WizardSignalTypeStepProps = {
actionSlug: string
municipalityName: string
municipalitySlug: string
entryAction?: CampaignWizardActionId
}
export const WizardSignalTypeStep = ({
actionSlug,
municipalityName,
municipalitySlug,
entryAction,
}: WizardSignalTypeStepProps) => {
const router = useRouter()
const [infoType, setInfoType] = useState<MunicipalitySignalType | null>(null)
const infoEntry = infoType
? municipalitySignalTypeMeta.find((entry) => entry.type === infoType)
: null
const showSkip = shouldShowWizardSignalSkip(entryAction)
return (
<>
<CampaignWizardShell
stepTitle={WIZARD_SIGNAL_TYPE_STEP_TITLE}
previousHref={wizardActionHref(actionSlug)}
municipalityLabel={municipalityName}
contentAlign="end"
trailingAction={
showSkip ? (
<Button variant="link" size="sm" className="h-auto px-2 py-1 text-xs" asChild>
<Link href={wizardSignalSkipHref()}>{WIZARD_SIGNAL_SKIP_LABEL}</Link>
</Button>
) : undefined
}
>
<ul className="grid list-none grid-cols-2 gap-3 md:grid-cols-3">
{municipalitySignalTypeMeta.map((entry) => {
const Icon = entry.icon
return (
<li key={entry.type} className="relative">
<button
type="button"
className={cn(
'flex aspect-square w-full flex-col justify-between rounded-lg border border-border bg-transparent p-3 pr-10 text-left',
'transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
)}
onClick={() =>
router.push(
wizardSignalHref(actionSlug, municipalitySlug, entry.type, entryAction),
)
}
>
<Icon className="size-5 shrink-0 text-foreground" aria-hidden />
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-foreground">{entry.label}</span>
<span className="line-clamp-2 text-xs text-muted-foreground">
{entry.shortDescription}
</span>
</div>
</button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1 size-8"
aria-label={`Informações sobre ${entry.label}`}
onClick={() => setInfoType(entry.type)}
>
<Info className="size-4" aria-hidden />
</Button>
</li>
)
})}
</ul>
</CampaignWizardShell>
<Drawer open={infoEntry != null} onOpenChange={(open) => !open && setInfoType(null)}>
<DrawerContent>
{infoEntry ? (
<>
<DrawerHeader>
<DrawerTitle>{infoEntry.label}</DrawerTitle>
<DrawerDescription>{infoEntry.shortDescription}</DrawerDescription>
</DrawerHeader>
<div className="px-4 pb-2">
<p className="text-sm text-muted-foreground">{infoEntry.infoContent}</p>
</div>
<DrawerFooter>
<DrawerClose render={<Button variant="outline" className="min-h-11 w-full" />}>
Fechar
</DrawerClose>
</DrawerFooter>
</>
) : null}
</DrawerContent>
</Drawer>
</>
)
}

View file

@ -1,5 +1,10 @@
/** Client-safe entry paths for UX-1 action wizards under `/campanha/acoes`. */
import {
parseMunicipalitySignalType,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
export const CAMPAIGN_ACTIONS_HOME = '/campanha/acoes' as const
export type CampaignWizardActionId =
@ -42,6 +47,8 @@ export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
/** Legacy B61 ritual param — canonical URLs omit it (B77). */
export const WIZARD_SCENARIO_QUERY_KEY = 'cenario' as const
export const WIZARD_SIGNAL_TYPE_QUERY_KEY = 'signalType' as const
export const WIZARD_ENTRY_ACTION_QUERY_KEY = 'entry' as const
export type WizardActionHrefOptions = {
@ -85,3 +92,41 @@ export const parseWizardEntryActionParam = (
}
return trimmed
}
export const wizardSignalHref = (
actionSlug: string,
municipalitySlug?: string,
signalType?: MunicipalitySignalType,
entryAction?: CampaignWizardActionId,
): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
if (signalType) {
params.set(WIZARD_SIGNAL_TYPE_QUERY_KEY, signalType)
}
if (entryAction) {
params.set(WIZARD_ENTRY_ACTION_QUERY_KEY, entryAction)
}
return `${base}?${params.toString()}`
}
export const resolveWizardSignalTypeParam = (
value: string | string[] | undefined,
): { signalType?: MunicipalitySignalType; invalid: boolean } => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
if (!trimmed) {
return { signalType: undefined, invalid: false }
}
const signalType = parseMunicipalitySignalType(trimmed)
if (signalType) {
return { signalType, invalid: false }
}
return { signalType: undefined, invalid: true }
}

View file

@ -0,0 +1,53 @@
import {
CircleHelp,
Handshake,
ShieldAlert,
Snowflake,
UserRoundX,
type LucideIcon,
} from 'lucide-react'
import {
municipalitySignalTypeDescriptions,
municipalitySignalTypeLabels,
municipalitySignalTypes,
type MunicipalitySignalType,
} from '@/lib/schemas/municipalityUpdate'
export type MunicipalitySignalTypeMetaEntry = {
type: MunicipalitySignalType
label: string
shortDescription: string
infoContent: string
icon: LucideIcon
}
const signalTypeIcons: Record<MunicipalitySignalType, LucideIcon> = {
invasao: ShieldAlert,
esfriamento: Snowflake,
visita_adversario: UserRoundX,
proposta_broker: Handshake,
outro: CircleHelp,
}
const signalTypeInfoContent: Record<MunicipalitySignalType, string> = {
invasao:
'O adversário está ocupando espaço que era nosso: reunião, base, narrativa ou presença no território. Consequência: a rede pode esfriar ou a leitura do município muda. Use quando a equipe perceber invasão real, não só boatos.',
esfriamento:
'Aliados pararam de responder, sumiram das conversas ou a rede perdeu ritmo. Consequência: cobertura e pledges podem estagnar. Use quando o silêncio for novo e relevante para a decisão do município.',
visita_adversario:
'O adversário apareceu no município — visita, agenda pública ou movimento visível. Consequência: pode pressionar lideranças ou mudar a leitura local. Use quando houver fato concreto, não só expectativa.',
proposta_broker:
'Alguém pediu ou ofereceu algo em troca de apoio, voto ou posicionamento. Consequência: pode virar demanda, risco ou oportunidade de alinhamento. Use quando a conversa for relevante para a campanha.',
outro:
'Fato importante que não encaixa nos tipos acima, mas merece registro datado. Consequência: alimenta o histórico do município e pode acionar sugestões depois. Use com parcimônia — prefira um tipo específico quando couber.',
}
export const municipalitySignalTypeMeta: MunicipalitySignalTypeMetaEntry[] =
municipalitySignalTypes.map((type) => ({
type,
label: municipalitySignalTypeLabels[type],
shortDescription: municipalitySignalTypeDescriptions[type],
infoContent: signalTypeInfoContent[type],
icon: signalTypeIcons[type],
}))

19
src/lib/wizardSignalUi.ts Normal file
View file

@ -0,0 +1,19 @@
import type { CampaignWizardActionId } from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
export const WIZARD_SIGNAL_TYPE_STEP_TITLE = 'Que tipo de sinal?' as const
export const WIZARD_SIGNAL_BODY_STEP_TITLE_PREFIX = 'Detalhar sinal' as const
export const WIZARD_SIGNAL_SKIP_LABEL = 'Pular registro de sinal' as const
export const WIZARD_SIGNAL_SAVE_LABEL = 'Salvar' as const
export const WIZARD_SIGNAL_SAVED_MESSAGE = 'Sinal registrado.' as const
export const wizardSignalSkipHref = (): string => CAMPAIGN_HOME
/** Skip is visible only when the wizard was embedded from another flow (not standalone register-signal). */
export const shouldShowWizardSignalSkip = (
entryAction: CampaignWizardActionId | undefined,
): boolean => entryAction != null && entryAction !== 'register-signal'

View file

@ -280,3 +280,76 @@ test.describe('Wizard — atualizar liderança (B70)', () => {
await expect(page.getByLabel('Buscar na campanha')).toBeVisible()
})
})
test.describe('Wizard — registrar sinal (B63)', () => {
test('standalone flow: type grid, body step, save returns to Início', 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/registrar-sinal?municipio=cairu')
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
timeout: 15000,
})
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toHaveCount(0)
await page
.getByRole('listitem')
.filter({ hasText: 'Invasão' })
.getByRole('button')
.first()
.click()
await page.waitForURL(/signalType=invasao/)
await expect(page.getByRole('heading', { name: /Detalhar sinal: Invasão/i })).toBeVisible()
const body = page.getByLabel('O que aconteceu?')
await body.fill('Adversário marcou presença no centro do município.')
await page.getByRole('button', { name: 'Salvar' }).click()
await page.waitForURL(/\/campanha$/)
await expect(page.getByText('Sinal registrado.')).toBeVisible()
})
test('info button opens drawer without advancing to body step', 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/registrar-sinal?municipio=cairu')
await expect(page.getByRole('heading', { name: 'Que tipo de sinal?' })).toBeVisible({
timeout: 15000,
})
await page.getByRole('button', { name: /Informações sobre Invasão/i }).click()
await expect(page.getByRole('dialog')).toBeVisible()
await expect(page.getByText(/O adversário está ocupando espaço/i)).toBeVisible()
await expect(page).not.toHaveURL(/signalType=/)
await page.getByRole('button', { name: 'Fechar' }).click()
await expect(page.getByRole('dialog')).toHaveCount(0)
})
test('embedded flow shows skip link', 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/registrar-sinal?municipio=cairu&entryAction=update-votes')
await expect(page.getByRole('link', { name: 'Pular registro de sinal' })).toBeVisible({
timeout: 15000,
})
})
})

View file

@ -10,7 +10,9 @@ import {
isCampaignWizardActionSlug,
parseWizardEntryActionParam,
parseWizardMunicipioParam,
resolveWizardSignalTypeParam,
wizardActionHref,
wizardSignalHref,
} from '@/lib/campaignActionRoutes'
describe('campaignActionRoutes', () => {
@ -62,7 +64,35 @@ describe('campaignActionRoutes', () => {
it('parses entry action search param', () => {
expect(parseWizardEntryActionParam('update-leadership')).toBe('update-leadership')
expect(parseWizardEntryActionParam('update-votes')).toBe('update-votes')
expect(parseWizardEntryActionParam('invalid')).toBeUndefined()
expect(parseWizardEntryActionParam(undefined)).toBeUndefined()
})
it('builds signal wizard hrefs with optional signalType and entry', () => {
expect(wizardSignalHref('registrar-sinal', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu`,
)
expect(wizardSignalHref('registrar-sinal', 'cairu', 'invasao')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=invasao`,
)
expect(wizardSignalHref('registrar-sinal', 'cairu', 'esfriamento', 'update-votes')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/registrar-sinal?municipio=cairu&signalType=esfriamento&entry=update-votes`,
)
})
it('resolves signalType search param with invalid flag', () => {
expect(resolveWizardSignalTypeParam(undefined)).toEqual({
signalType: undefined,
invalid: false,
})
expect(resolveWizardSignalTypeParam('invasao')).toEqual({
signalType: 'invasao',
invalid: false,
})
expect(resolveWizardSignalTypeParam('invalid')).toEqual({
signalType: undefined,
invalid: true,
})
})
})

View file

@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { municipalitySignalTypeMeta } from '@/lib/municipalitySignalTypeMeta'
import { municipalitySignalTypes } from '@/lib/schemas/municipalityUpdate'
import { shouldShowWizardSignalSkip } from '@/lib/wizardSignalUi'
describe('wizardSignalUi', () => {
it('hides skip for standalone register-signal entry', () => {
expect(shouldShowWizardSignalSkip(undefined)).toBe(false)
expect(shouldShowWizardSignalSkip('register-signal')).toBe(false)
})
it('shows skip when embedded from another wizard action', () => {
expect(shouldShowWizardSignalSkip('update-votes')).toBe(true)
expect(shouldShowWizardSignalSkip('change-trend')).toBe(true)
})
})
describe('municipalitySignalTypeMeta', () => {
it('covers every signal type with icon and info copy', () => {
expect(municipalitySignalTypeMeta).toHaveLength(municipalitySignalTypes.length)
for (const type of municipalitySignalTypes) {
const entry = municipalitySignalTypeMeta.find((item) => item.type === type)
expect(entry?.label.length).toBeGreaterThan(0)
expect(entry?.shortDescription.length).toBeGreaterThan(0)
expect(entry?.infoContent.length).toBeGreaterThan(20)
expect(entry?.icon).toBeTruthy()
}
})
})