Merge branch 'cursor/18dd8494' (B59 wizard chassis)
This commit is contained in:
commit
e1eafe3145
16 changed files with 301 additions and 8 deletions
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -93,6 +93,14 @@ Componentes:
|
|||
|
||||
- **Persistência de rascunho (município + ação) ao reabrir.** Revisitar quando a 1ª sessão real for interrompida por Zap e o CG reclamar (UX-1 § contrato #8).
|
||||
- **Caption do município como `Link` para a ficha.** Revisitar se a mesa pedir “abrir município” sem sair do fluxo (ex. Sheet de leitura) — v1 é texto só.
|
||||
- **Split server shell + client focus island.** Revisitar em **B60** se o passo de busca não precisar hidratar o header inteiro (`CampaignWizardShell` hoje é `'use client'` por um `focus()` no `h1`).
|
||||
- **`backdrop-blur` no header sticky.** Revisitar se scroll longo em B60+ mostrar custo de paint; v1 mantém blur alinhado ao app chrome.
|
||||
|
||||
## Já resolvido no simplify (não reabrir)
|
||||
|
||||
- Cinco `page.tsx` duplicados → rota dinâmica `acoes/[slug]/page.tsx` + `WizardMunicipalityPlaceholderStep`.
|
||||
- `CAMPAIGN_HOME` em `campaignPaths.ts`; `WIZARD_MUNICIPALITY_STEP_TITLE` em `campaignWizardCopy.ts`.
|
||||
- `aria-labelledby` no `<main>`; `data-slot="wizard-mobile-back"` nos testes.
|
||||
|
||||
## Referências
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Roadmap — Teqo
|
||||
|
||||
Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓; ~~**B67**~~ ✓; **B68**; **B66**; **B59** sticky; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B59–B64.
|
||||
Atualizado em: 2026-07-29 — **B69**–**B70** A4 liderança (exclusivo+limpeza → wizard grid); ~~**B56**~~ ✓ resumo; ~~**B67**~~ ✓; **B68**; **B66**; ~~**B59**~~ ✓ chassis wizard; ~~**B58**~~ ✓; **B65**; B48 ✓; B49–B55; wizards B60–B64.
|
||||
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** · **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**~~ ✓ → **B68** (sugestões empty/focus por papel) → **B49–B55** · ~~**B56**~~ ✓ (resumo) · **B57** futuro. **Wizards:** ~~**B59**~~ ✓ → **B60**/**B61**/**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)
|
||||
- **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)
|
||||
|
|
@ -232,7 +232,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"]
|
||||
|
|
|
|||
25
src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
Normal file
25
src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
Normal 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 />
|
||||
}
|
||||
9
src/app/(campaign)/campanha/(app)/acoes/layout.tsx
Normal file
9
src/app/(campaign)/campanha/(app)/acoes/layout.tsx
Normal 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>
|
||||
}
|
||||
84
src/components/campaign/shared/CampaignWizardShell.tsx
Normal file
84
src/components/campaign/shared/CampaignWizardShell.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
28
src/lib/campaignActionRoutes.ts
Normal file
28
src/lib/campaignActionRoutes.ts
Normal 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]}`
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
5
src/lib/campaignWizardCopy.ts
Normal file
5
src/lib/campaignWizardCopy.ts
Normal 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.'
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
29
tests/unit/campaignActionRoutes.unit.spec.ts
Normal file
29
tests/unit/campaignActionRoutes.unit.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
77
tests/unit/campaignWizardShell.unit.spec.tsx
Normal file
77
tests/unit/campaignWizardShell.unit.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue