B60: wizard municipality search step with shared staff loader.

Staff wizards pick a municipality via word-start search, auto-advance with ?municipio=, and reuse searchStaffMunicipalityHits extracted from B48.
This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 23:09:00 -03:00
parent fe3d1d8bf6
commit f6b73b3e63
20 changed files with 513 additions and 113 deletions

View file

@ -7,6 +7,7 @@ alwaysApply: false
## Status
- **B60 entregue em código (2026-07-29):** etapa de busca de município nos wizards UX-1 — `WizardMunicipalitySearchStep` (debounce B47, `POST /campanha/home-search` modo `wizard-municipality`, só municípios scoped); select = `wizardActionHref` com `?municipio=<slug>`; loader compartilhado `searchStaffMunicipalityHits` (extraído de B48); `HomeSearchMunicipalityVoteTrailing` + `HomeSearchHitRow` link; stub B61 `WizardMunicipalitySelectedStub` com caption sticky; `WizardMunicipalityPlaceholderStep` removido. Sem migration/Consent. Unit `campaignActionRoutes`; int `searchStaffMunicipalityHits`; e2e wizard → `?municipio=cairu`. Plano: [`busca-municipio-wizard.md`](../../../docs/plans/busca-municipio-wizard.md). Desbloqueia **B61** / **B63** / **B64** / **B70**.
- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Atividades); `POST /campanha/home-search` agrega `stateDeputies`; match word-start em nome ou partido; secundário `partido · N municípios`; assessor vê hits (staff read); `municipalityIdsByStateDeputyIds` em `stateDeputyData`. Int `homeSearchStateDeputies.int.spec.ts`. WA = **B55** (sem telefone na entidade). Plano: [`busca-global-resultados-dobradinhas.md`](../../../docs/plans/busca-global-resultados-dobradinhas.md). Próximo na cadeia busca: **B53** demandas.
- **B72 entregue em código (2026-07-29):** gap horizontal da strip de ações do Início (UX-1) — `CampaignHomeActionStrip` `<ul>` `gap-6` → `gap-4` (16px); `w-[4.75rem]`, círculo 48px, `snap-x`, scrollbar oculta e pan B67 ✓ inalterados. Sem migration/action/URL. Unit strip 11/11 em `campaignHomeActionButton.unit.spec.tsx`. Plano: [`espacamento-strip-acoes-inicio.md`](../../../docs/plans/espacamento-strip-acoes-inicio.md).
- **B65 entregue em código (2026-07-29):** âncora inferior mobile no Início — cadeia `h-full min-h-0` em `CampaignPageShell` + `CampaignHomeStaffChrome` + `CampaignHomeLayout`; wrapper `data-slot="home-dock"` agrupa strip B45 + busca B47; spacer `flex-1` empurra o dock acima da bottom nav (`pb-24` intacto); `md+` inalterado; modo focado B47/B66 intacto; sem `fixed`/`mt-auto`. Unit `campaignHomeLayout.unit.spec.tsx` (6 casos). Plano: [`ancorar-busca-acoes-inicio-mobile.md`](../../../docs/plans/ancorar-busca-acoes-inicio-mobile.md). Desbloqueia animação idle de **B66**. Débito adiado: ajuste fino `pb-24` vs altura real da bottom nav se gap >~8 px em device real.

View file

@ -1,12 +1,50 @@
# Etapa de busca de município nos wizards
Status: rascunho
Status: **entregue** (2026-07-29)
Atualizado em: 2026-07-29
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B60 — UX-1 wizards)
Impeccable: B — encaixe da UX da busca do Início (B47/B48) numa etapa de wizard, só municípios
Appetite: ~1 dia eng; input + lista de hits + auto-avanço; soft reuso do loader B48
Responsável: —
## As-built (entrega)
### Contrato de URL
- `?municipio=<slug>` na rota atual `/campanha/acoes/<actionSlug>` (sem nested routes).
- Helpers client-safe em `src/lib/campaignActionRoutes.ts`: `wizardActionHref`, `parseWizardMunicipioParam`, `WIZARD_MUNICIPIO_QUERY_KEY`.
- Refresh em `?municipio=` válido mantém caption; slug fora do escopo → `notFound()`.
### Loader
- `searchStaffMunicipalityHits` (`src/utilities/homeSearch/searchStaffMunicipalityHits.ts`) — núcleo municipality-only extraído de B48.
- `searchHomeMunicipalities` refatorado para chamar o helper + bloco de territórios inalterado.
- `POST /campanha/home-search` com `mode: 'wizard-municipality'` → `{ status: 'success', municipalities }`.
### UI
- `WizardMunicipalitySearchStep` — `CampaignSearchInput` + `useHomeSearchQuery` + lista com `HomeSearchHitRow` variante `button` (`role="option"`).
- Select = `router.push(wizardActionHref(actionSlug, slug))` — sem botão Continuar.
- Empty: "Nenhum município encontrado."; erro via `HOME_SEARCH_GENERIC_ERROR_MESSAGE`.
- Readout 2022: `MunicipalityVotePositionReadout layout="search"` (paridade B48).
- Pós-seleção: `WizardMunicipalitySelectedStub` (placeholder B61) com `municipalityLabel` no shell; Voltar → passo de busca.
### Removido
- `WizardMunicipalityPlaceholderStep` (substituído pela etapa real + stub B61).
### Adiado
- Chips de recentes/prioritários — módulo `recentVisits` não existe no repo; revisitar se o CG pedir atalho sem digitar.
### Testes
- Unit: `campaignActionRoutes` (`wizardActionHref`, `parseWizardMunicipioParam`).
- Int: `searchStaffMunicipalityHits.int.spec.ts` (match, escopo assessor, leader rejeitado).
- E2e: `campaignHomeActions.e2e.spec.ts` — Início → Ajustar votos → buscar Cairu → `?municipio=cairu` + caption.
### Sem migration · sem Consent · staff-only (layout `acoes/` já gateia)
## Design (Impeccable)
Âncoras: `PRODUCT.md` / `DESIGN.md` · B47 `CampaignHomeSearch` / B48 linhas de município · tema `campaign`.
@ -23,7 +61,7 @@ Brief compacto:
## Dados → decisão → apresentação
- **Vou apresentar dados?** Sim — hits de município (nome, TI, opcional “2022” se B48 já tiver o readout).
- **Vou apresentar dados?** Sim — hits de município (nome, TI, readout 2022).
- **Decisões:** escolher **qual** município entra no fluxo.
- **Forma:** lista ranqueada por nome (word-start), igual espírito B48. **Rejeitado:** mapa; grupos multi-entidade.
- **Profile:** scoped ao access; tipicamente &lt;20 hits.
@ -35,62 +73,24 @@ No Início, **B47** ✓ entrega input + modo focado; **B48** lista Municípios (
Serve A1 (votos), A2 (sinal), A3/A4/A5 e qualquer fluxo que precise de local cedo ([fluxos-acao-primeiro-inicio.md](fluxos-acao-primeiro-inicio.md) § contrato #2).
## Objetivos
- Etapa reutilizável (ex. `WizardMunicipalitySearchStep`) montada no **B59**: input debounce (mesmo espírito 250 ms de B47), lista de hits **apenas** `municipality`, escopo `overrideAccess: false`.
- Clique/Enter no hit → callback/`router.push` para o próximo passo **com município escolhido** (slug ou id na URL/query do fluxo).
- Sem botão “Continuar” / “Confirmar município”.
- Recentres / prioritários opcionais acima da busca (soft; cortável) — 3 recentes se `recentVisits` já existir no Início.
- Match: `matchesAtWordStart` / `normalizeSearchPhrase` (`lib/wordStartFilter.ts`).
- Sem migration / Consent.
- Se **B48** já tiver `searchHomeMunicipalities`, **filtrar/omitir TIs** e reusar o loader; senão, loader mínimo só de municípios (depth: não duplicar fuzzy).
## Decisões travadas
- **Só municípios** — TIs fora deste passo (wizard grava em `municipality`). **Rejeitado:** copiar B48 com TIs (não há `municipality` para um TI).
- **Select = auto-avanço.** **Rejeitado:** seleção + “Continuar” (pedido explícito; atrito no ritual).
- **Query `?municipio=`** na rota atual. **Rejeitado:** nested `/acoes/<action>/<slug>` antes de B61; store client-only (perde refresh/Zap).
- **Mesma linguagem visual da busca do Início** (linha sem card, tipografia). **Rejeitado:** `Command`/`Combobox` de lista (B27) como UI primária — é outro modelo mental.
- **i18n:** `WizardMunicipalitySearch`, copy “Em qual município?”.
## Questões em aberto
- **Mostrar votos 2022 à direita no wizard?** **Opções:** A sim (paridade B48) | B só nome+TI. **Recomendação:** B no v1 se B48 ainda não landou; A quando o readout existir e não atrasar o appetite. _(assumido)_
## Abordagem proposta
```mermaid
flowchart LR
Shell["B59 Shell"] --> Input["input debounce"]
Input --> Loader["searchMunicipalitiesScoped"]
Loader --> Hits["lista só município"]
Hits -->|select| Next["próximo passo + slug"]
```
Componentes:
- **`WizardMunicipalitySearchStep`** (client island): input + results; registra seleção.
- **Loader** — reuso B48 ou `utilities/municipality/` com `overrideAccess: false`.
- Reuso: `useHomeSearchQuery` pattern / debounce B47; **não** importar o chrome focado do Início inteiro.
- **Migration:** Sem migration.
- **Readout 2022:** sim (paridade B48 — Opção A).
- **i18n:** copy “Em qual município?”.
## Dependências
- Dura: **B59**. Soft: **B48** (UX/loader), **B47** ✓ (debounce/modo focado como referência).
- Dura: **B59** ✓. Soft: **B48** ✓ (UX/loader), **B47** ✓ (debounce).
## Não escopo
- Grupos lideranças/assessores/… → B49–B53 (só Início). Ajuste de votos → **B61**. Chassis → **B59**.
## Rabbit holes
- **Um único componente de busca para Início e wizard.** **Mitigação:** compartilhar loader + row renderer; chrome (grupos vs só município, onSelect vs Link) fica nos wrappers — extrair shared só com 2 call sites reais medidos.
## Adiado com gatilho
- **Chips de recentes/prioritários acima do input.** Revisitar se o CG pedir atalho sem digitar (sessão).
- Grupos lideranças/assessores/… → B49–B53 (só Início). Ajuste de votos → **B61**. Chassis → **B59** ✓.
## Referências
- [busca-global-inicio-input.md](busca-global-inicio-input.md) · [busca-global-resultados-municipios.md](busca-global-resultados-municipios.md) · [chassis-wizard-campanha.md](chassis-wizard-campanha.md) · `lib/wordStartFilter.ts` · `recentVisits` utilities
- [busca-global-inicio-input.md](busca-global-inicio-input.md) · [busca-global-resultados-municipios.md](busca-global-resultados-municipios.md) · [chassis-wizard-campanha.md](chassis-wizard-campanha.md) · `lib/wordStartFilter.ts`
- AGENTS.md — access advisor; `overrideAccess: false`
- `PRODUCT.md` / `DESIGN.md`

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards B60–B64.
Atualizado em: 2026-07-29 — ~~**B51**~~ ✓; ~~**B52**~~ ✓; ~~**B72**~~ ✓ gap strip ações; **B71** polish busca (bullets/título/hover full-bleed); **B69**–**B70** A4 liderança; ~~**B56**~~ ✓; ~~**B67**~~ ✓; ~~**B68**~~ ✓; **B66**; ~~**B59**~~ ✓; ~~**B65**~~ ✓; ~~**B60**~~ ✓; ~~**B48**~~ ✓; ~~**B49**~~ ✓; ~~**B50**~~ ✓; B53–B55; wizards ~~B60~~ ✓/B61–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,14 +83,14 @@ 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) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **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**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → **B71** (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → ~~**B50**~~ ✓ → ~~**B51**~~ ✓ → ~~**B52**~~ ✓ → **B53–B55** · ~~**B56**~~ ✓ · **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~~ — **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)
- **B60** ~~Etapa busca município nos wizards~~ — **entregue 2026-07-29** (`WizardMunicipalitySearchStep`; `searchStaffMunicipalityHits` + modo `wizard-municipality` no `POST /campanha/home-search`; select → `?municipio=<slug>`; stub B61 com caption sticky; chips recentes adiados — sem `recentVisits` no repo) · [plano](plans/busca-municipio-wizard.md)
- **B61** Etapa ajuste de votos (média→pessimista→otimista; atalhos 2×/±50/±100; coerência com jump+warning) · 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)
- **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)

View file

@ -1,25 +1,56 @@
import { notFound } from 'next/navigation'
import { WizardMunicipalityPlaceholderStep } from '@/components/campaign/shared/WizardMunicipalityPlaceholderStep'
import { WizardMunicipalitySearchStep } from '@/components/campaign/shared/WizardMunicipalitySearchStep'
import { WizardMunicipalitySelectedStub } from '@/components/campaign/shared/WizardMunicipalitySelectedStub'
import {
CAMPAIGN_WIZARD_ACTION_SLUGS,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
WIZARD_MUNICIPIO_QUERY_KEY,
} from '@/lib/campaignActionRoutes'
import { CAMPAIGN_HOME } from '@/lib/campaignPaths'
import config from '@/payload.config'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import { getPayload } from 'payload'
type CampaignActionWizardPageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<Record<string, string | string[] | undefined>>
}
export function generateStaticParams() {
return Object.values(CAMPAIGN_WIZARD_ACTION_SLUGS).map((slug) => ({ slug }))
}
export default async function CampaignActionWizardPage({ params }: CampaignActionWizardPageProps) {
export default async function CampaignActionWizardPage({
params,
searchParams,
}: CampaignActionWizardPageProps) {
const { slug } = await params
const resolvedSearchParams = await searchParams
if (!isCampaignWizardActionSlug(slug)) {
notFound()
}
return <WizardMunicipalityPlaceholderStep />
const municipalitySlug = parseWizardMunicipioParam(
resolvedSearchParams[WIZARD_MUNICIPIO_QUERY_KEY],
)
if (!municipalitySlug) {
return <WizardMunicipalitySearchStep actionSlug={slug} previousHref={CAMPAIGN_HOME} />
}
const user = await requireCampaignPageActor()
const payload = await getPayload({ config: await config })
const { municipalities } = await loadMunicipalityScope(payload, user, {
slug: { equals: municipalitySlug },
})
const municipality = municipalities[0]
if (!municipality || municipality.slug !== municipalitySlug) {
notFound()
}
return <WizardMunicipalitySelectedStub actionSlug={slug} municipalityName={municipality.name} />
}

View file

@ -1,6 +1,9 @@
import { NextResponse } from 'next/server'
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
import type {
HomeSearchSuccessResponse,
WizardMunicipalitySearchSuccessResponse,
} from '@/lib/campaignHomeSearchHits'
import {
HOME_SEARCH_GENERIC_ERROR_MESSAGE,
HOME_SEARCH_STAFF_ONLY_MESSAGE,
@ -14,6 +17,7 @@ import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffMunicipalityHits'
export const dynamic = 'force-dynamic'
@ -31,6 +35,14 @@ export const POST = campaignJsonMutationRoute(
return NextResponse.json<HomeSearchSuccessResponse>(result)
}
if (body.mode === 'wizard-municipality') {
const municipalities = await searchStaffMunicipalityHits(payload, actor, body.query)
return NextResponse.json<WizardMunicipalitySearchSuccessResponse>({
status: 'success',
municipalities,
})
}
const [municipalityResult, advisors, leaderships, activities, stateDeputies] =
await Promise.all([
searchHomeMunicipalities(payload, actor, body.query),

View file

@ -24,7 +24,7 @@ export const HomeSearchHitRow = ({
<Link
href={href}
className={cn(
'flex min-h-11 items-center gap-3 py-2.5 text-foreground',
'flex min-h-11 w-full items-center gap-3 py-2.5 text-left text-foreground',
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
>

View file

@ -1,8 +1,8 @@
'use client'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
import { formatElectionNumber } from '@/lib/electionFormat'
import { buildTerritoryPageHref } from '@/lib/territoryAnchor'
@ -30,16 +30,7 @@ export const HomeSearchMunicipalityGroup = () => {
primary={hit.name}
secondary={hit.region}
showPriority={hit.priority === 'alta'}
trailing={
hit.votePosition2022 ? (
<MunicipalityVotePositionReadout
position={hit.votePosition2022}
layout="search"
/>
) : (
<span className="text-sm text-muted-foreground">—</span>
)
}
trailing={<HomeSearchMunicipalityVoteTrailing position={hit.votePosition2022} />}
/>
</li>
))}

View file

@ -0,0 +1,17 @@
'use client'
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
type HomeSearchMunicipalityVoteTrailingProps = {
position: MunicipalityVoteRankEntry | null
}
export const HomeSearchMunicipalityVoteTrailing = ({
position,
}: HomeSearchMunicipalityVoteTrailingProps) =>
position ? (
<MunicipalityVotePositionReadout position={position} layout="search" />
) : (
<span className="text-sm text-muted-foreground">—</span>
)

View file

@ -1,12 +0,0 @@
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,136 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashboard/HomeSearchMunicipalityVoteTrailing'
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import type {
HomeSearchMunicipalityHit,
WizardMunicipalitySearchSuccessResponse,
} from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_GENERIC_ERROR_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { postCampaignJson } from '@/lib/campaignJsonRequest'
import {
WIZARD_MUNICIPALITY_SEARCH_EMPTY,
WIZARD_MUNICIPALITY_SEARCH_LABEL,
WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER,
WIZARD_MUNICIPALITY_STEP_TITLE,
} from '@/lib/campaignWizardCopy'
import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch'
const HOME_SEARCH_ROUTE = '/campanha/home-search'
const WIZARD_MUNICIPALITY_SEARCH_INPUT_ID = 'wizardMunicipalitySearchQuery'
type WizardSearchResultsState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; municipalities: HomeSearchMunicipalityHit[] }
| { status: 'error'; message: string }
type WizardMunicipalitySearchStepProps = {
actionSlug: string
previousHref: string
}
export const WizardMunicipalitySearchStep = ({
actionSlug,
previousHref,
}: WizardMunicipalitySearchStepProps) => {
const { query, setRaw, isDebouncing } = useHomeSearchQuery()
const [results, setResults] = useState<WizardSearchResultsState>({ status: 'idle' })
const requestSeq = useRef(0)
const resultsBusy = isDebouncing || results.status === 'loading'
useEffect(() => {
if (!query.isActive) {
requestSeq.current += 1
setResults({ status: 'idle' })
return
}
const seq = ++requestSeq.current
const controller = new AbortController()
setResults({ status: 'loading' })
void (async () => {
const { ok, payload } = await postCampaignJson<WizardMunicipalitySearchSuccessResponse>(
HOME_SEARCH_ROUTE,
{ mode: 'wizard-municipality', query: query.debounced },
controller.signal,
)
if (seq !== requestSeq.current) return
if (!ok || payload.status !== 'success') {
setResults({ status: 'error', message: HOME_SEARCH_GENERIC_ERROR_MESSAGE })
return
}
setResults({ status: 'success', municipalities: payload.municipalities })
})().catch((error: unknown) => {
if (seq !== requestSeq.current) return
if (error instanceof DOMException && error.name === 'AbortError') return
setResults({ status: 'error', message: HOME_SEARCH_GENERIC_ERROR_MESSAGE })
})
return () => {
controller.abort()
}
}, [query.debounced, query.isActive])
const showEmpty =
query.isActive && results.status === 'success' && results.municipalities.length === 0
return (
<CampaignWizardShell stepTitle={WIZARD_MUNICIPALITY_STEP_TITLE} previousHref={previousHref}>
<div className="flex flex-col gap-4">
<CampaignSearchInput
id={WIZARD_MUNICIPALITY_SEARCH_INPUT_ID}
label={WIZARD_MUNICIPALITY_SEARCH_LABEL}
placeholder={WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER}
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
autoComplete="off"
enterKeyHint="search"
aria-busy={resultsBusy || undefined}
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
/>
<div
role="region"
aria-live="polite"
aria-label="Resultados da busca"
aria-busy={resultsBusy || undefined}
className="min-w-0"
>
{results.status === 'error' ? (
<p className="text-sm text-destructive" role="alert">
{results.message}
</p>
) : null}
{results.status === 'success' && results.municipalities.length > 0 ? (
<ul className="flex flex-col">
{results.municipalities.map((hit) => (
<li key={hit.slug}>
<HomeSearchHitRow
href={wizardActionHref(actionSlug, hit.slug)}
primary={hit.name}
secondary={hit.region}
showPriority={hit.priority === 'alta'}
trailing={<HomeSearchMunicipalityVoteTrailing position={hit.votePosition2022} />}
/>
</li>
))}
</ul>
) : null}
{showEmpty ? (
<p className="text-sm text-muted-foreground">{WIZARD_MUNICIPALITY_SEARCH_EMPTY}</p>
) : null}
</div>
</div>
</CampaignWizardShell>
)
}

View file

@ -0,0 +1,21 @@
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import { wizardNextStepPlaceholder, wizardNextStepTitle } from '@/lib/campaignWizardCopy'
type WizardMunicipalitySelectedStubProps = {
actionSlug: string
municipalityName: string
}
export const WizardMunicipalitySelectedStub = ({
actionSlug,
municipalityName,
}: WizardMunicipalitySelectedStubProps) => (
<CampaignWizardShell
stepTitle={wizardNextStepTitle(actionSlug)}
previousHref={wizardActionHref(actionSlug)}
municipalityLabel={municipalityName}
>
<p className="text-sm text-muted-foreground">{wizardNextStepPlaceholder(actionSlug)}</p>
</CampaignWizardShell>
)

View file

@ -26,3 +26,22 @@ export const isCampaignWizardActionId = (id: string): id is CampaignWizardAction
export const campaignActionEntryHref = (id: CampaignWizardActionId): string =>
`${CAMPAIGN_ACTIONS_HOME}/${CAMPAIGN_WIZARD_ACTION_SLUGS[id]}`
export const WIZARD_MUNICIPIO_QUERY_KEY = 'municipio' as const
export const wizardActionHref = (actionSlug: string, municipalitySlug?: string): string => {
const base = `${CAMPAIGN_ACTIONS_HOME}/${actionSlug}`
if (!municipalitySlug) {
return base
}
const params = new URLSearchParams({ [WIZARD_MUNICIPIO_QUERY_KEY]: municipalitySlug })
return `${base}?${params.toString()}`
}
export const parseWizardMunicipioParam = (
value: string | string[] | undefined,
): string | undefined => {
const raw = Array.isArray(value) ? value[0] : value
const trimmed = raw?.trim()
return trimmed ? trimmed : undefined
}

View file

@ -100,3 +100,8 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
homeSearchLeadershipGroupHasHits(data) ||
homeSearchStateDeputyGroupHasHits(data) ||
homeSearchActivityGroupHasHits(data)
export type WizardMunicipalitySearchSuccessResponse = {
status: 'success'
municipalities: HomeSearchMunicipalityHit[]
}

View file

@ -1,5 +1,30 @@
/** Shared copy for UX-1 wizard steps until B60 ships municipality search. */
import { CAMPAIGN_WIZARD_ACTION_SLUGS } from '@/lib/campaignActionRoutes'
/** Shared copy for UX-1 wizard municipality step (B60). */
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.'
export const WIZARD_MUNICIPALITY_SEARCH_LABEL = 'Buscar município' as const
export const WIZARD_MUNICIPALITY_SEARCH_PLACEHOLDER = 'Nome do município…' as const
export const WIZARD_MUNICIPALITY_SEARCH_EMPTY = 'Nenhum município encontrado.' as const
/** Placeholder until B61 ships vote adjustment. */
const WIZARD_NEXT_STEP_VOTES_PLACEHOLDER =
'Próximo passo: ajuste de votos estimados neste município.' as const
const WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER = 'Próximo passo deste fluxo em breve.' as const
export const wizardNextStepTitle = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return 'Ajustar votos estimados'
}
return 'Continuar'
}
export const wizardNextStepPlaceholder = (actionSlug: string): string => {
if (actionSlug === CAMPAIGN_WIZARD_ACTION_SLUGS['update-votes']) {
return WIZARD_NEXT_STEP_VOTES_PLACEHOLDER
}
return WIZARD_NEXT_STEP_GENERIC_PLACEHOLDER
}

View file

@ -18,4 +18,8 @@ export const homeSearchBodySchema = z.discriminatedUnion('mode', [
z.object({
mode: z.literal('suggest'),
}),
z.object({
mode: z.literal('wizard-municipality'),
query: homeSearchQueryField,
}),
])

View file

@ -4,8 +4,6 @@ import type { Payload } from 'payload'
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
import {
toHomeSearchMunicipalityHit,
type HomeSearchMunicipalityHit,
type HomeSearchSuccessResponse,
type HomeSearchTerritoryHit,
} from '@/lib/campaignHomeSearchHits'
@ -18,9 +16,10 @@ import {
import { DEFAULT_VOTE_RANK_YEAR } from '@/lib/municipalityVoteRank'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
import { loadTerritoryOverview } from '@/utilities/territory/loadTerritoryOverview'
import { searchStaffMunicipalityHits } from './searchStaffMunicipalityHits'
const emptyResponse = (): HomeSearchSuccessResponse => ({
status: 'success',
resultKind: 'search',
@ -48,36 +47,10 @@ export const searchHomeMunicipalities = async (
const normalizedQuery = normalizeHomeSearchName(query)
const { municipalities } = await loadMunicipalityScope(payload, user, {})
const municipalityHits: HomeSearchMunicipalityHit[] = municipalities
.map((doc) => {
const normalizedName = normalizeHomeSearchName(doc.name)
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
return null
}
return {
normalizedName,
hit: toHomeSearchMunicipalityHit(doc),
}
})
.filter((row): row is NonNullable<typeof row> => row !== null)
.sort((left, right) =>
compareHomeSearchNameRelevance(
{
normalizedName: left.normalizedName,
tieBreakDesc: left.hit.votePosition2022?.votes ?? 0,
},
{
normalizedName: right.normalizedName,
tieBreakDesc: right.hit.votePosition2022?.votes ?? 0,
},
normalizedQuery,
),
)
.map((row) => row.hit)
const territoryRows = await loadTerritoryOverview(payload, user)
const [municipalityHits, territoryRows] = await Promise.all([
searchStaffMunicipalityHits(payload, user, query),
loadTerritoryOverview(payload, user),
])
const territoryHits: HomeSearchTerritoryHit[] = territoryRows
.map((row) => {
const normalizedName = normalizeHomeSearchName(row.region)

View file

@ -0,0 +1,63 @@
import 'server-only'
import type { Payload } from 'payload'
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
import {
toHomeSearchMunicipalityHit,
type HomeSearchMunicipalityHit,
} from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import {
compareHomeSearchNameRelevance,
normalizeHomeSearchName,
} from '@/lib/homeSearchMunicipalityMatch'
import { matchesNormalizedAtWordStart } from '@/lib/wordStartFilter'
import type { CampaignUser } from '@/payload-types'
import { loadMunicipalityScope } from '@/utilities/municipality/campaignMunicipalityScope'
export const searchStaffMunicipalityHits = async (
payload: Payload,
user: CampaignUser,
rawQuery: string,
): Promise<HomeSearchMunicipalityHit[]> => {
if (!isStaffCampaignRole(user.role)) {
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
}
const query = normalizeHomeSearchRaw(rawQuery)
if (!homeSearchQueryIsActive(query)) {
return []
}
const normalizedQuery = normalizeHomeSearchName(query)
const { municipalities } = await loadMunicipalityScope(payload, user, {})
return municipalities
.map((doc) => {
const normalizedName = normalizeHomeSearchName(doc.name)
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
return null
}
return {
normalizedName,
hit: toHomeSearchMunicipalityHit(doc),
}
})
.filter((row): row is NonNullable<typeof row> => row !== null)
.sort((left, right) =>
compareHomeSearchNameRelevance(
{
normalizedName: left.normalizedName,
tieBreakDesc: left.hit.votePosition2022?.votes ?? 0,
},
{
normalizedName: right.normalizedName,
tieBreakDesc: right.hit.votePosition2022?.votes ?? 0,
},
normalizedQuery,
),
)
.map((row) => row.hit)
}

View file

@ -121,3 +121,30 @@ test.describe('Início — catálogo de ações (B45)', () => {
await expect(page.getByRole('heading', { name: /Meus contatos/ })).toBeVisible()
})
})
test.describe('Wizard — busca município (B60)', () => {
test('selecting a municipality advances with ?municipio= and sticky caption', async ({
campaign,
page,
}) => {
const { fixtures } = campaign
const coordinator = await fixtures.createCampaignUser('coordinator', {
name: fixtures.value('Coordenadora Geral'),
})
await campaign.login(page, coordinator.email!, coordinator.password)
await page.goto('/campanha/acoes/atualizar-votos')
await expect(page.getByRole('heading', { name: WIZARD_MUNICIPALITY_STEP_TITLE })).toBeVisible()
const search = page.getByLabel('Buscar município')
await search.fill('Cairu')
const results = page.getByRole('region', { name: 'Resultados da busca' })
await expect(results.getByRole('button', { name: /Cairu/i })).toBeVisible({ timeout: 15000 })
await results.getByRole('button', { name: /Cairu/i }).click()
await page.waitForURL(/\/campanha\/acoes\/atualizar-votos\?municipio=cairu/)
await expect(page.getByLabel(/Município em atualização: Cairu/i)).toBeVisible()
await expect(page.getByRole('heading', { name: 'Ajustar votos estimados' })).toBeVisible()
})
})

View file

@ -0,0 +1,71 @@
// @vitest-environment node
import type { Payload } from 'payload'
import { getPayload } from 'payload'
import { beforeAll, describe, expect, it } from 'vitest'
import { getMunicipalityCatalogEntry } from '@/lib/municipalityCatalog'
import config from '@/payload.config'
import { searchStaffMunicipalityHits } from '@/utilities/homeSearch/searchStaffMunicipalityHits'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
describe('searchStaffMunicipalityHits (B60)', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('returns empty hits when query is shorter than the minimum', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const result = await searchStaffMunicipalityHits(payload, coordinator, 'a')
expect(result).toEqual([])
})
it('matches municipalities by word-start on the name', async () => {
const fixtures = campaignFixtures()
const coordinator = await fixtures.createCampaignUser('coordinator')
const cairu = getMunicipalityCatalogEntry('cairu')
expect(cairu).toBeDefined()
const result = await searchStaffMunicipalityHits(payload, coordinator, 'Cai')
expect(result.some((hit) => hit.slug === 'cairu')).toBe(true)
})
it('scopes municipality hits to the advisor portfolio', async () => {
const fixtures = campaignFixtures()
const advisor = await fixtures.createCampaignUser('advisor')
const coordinator = await fixtures.createCampaignUser('coordinator')
const administered = await fixtures.getMunicipality('cairu')
const other = await fixtures.getMunicipality('feira-de-santana')
await fixtures.assignMunicipalityAdvisors(administered.id, [advisor.id])
const advisorResult = await searchStaffMunicipalityHits(payload, advisor, 'Feira')
expect(advisorResult.map((hit) => hit.slug)).toEqual([])
const coordinatorResult = await searchStaffMunicipalityHits(payload, coordinator, 'Feira')
expect(coordinatorResult.some((hit) => hit.slug === other.slug)).toBe(true)
const advisorCairu = await searchStaffMunicipalityHits(payload, advisor, 'Cairu')
expect(advisorCairu.map((hit) => hit.slug)).toEqual(['cairu'])
})
it('rejects leaders from wizard municipality search', async () => {
const fixtures = campaignFixtures()
const leader = await fixtures.createCampaignUser('leader')
await expect(searchStaffMunicipalityHits(payload, leader, 'Cairu')).rejects.toThrow(
/equipe de campanha/i,
)
})
})

View file

@ -6,6 +6,8 @@ import {
campaignActionEntryHref,
isCampaignWizardActionId,
isCampaignWizardActionSlug,
parseWizardMunicipioParam,
wizardActionHref,
} from '@/lib/campaignActionRoutes'
describe('campaignActionRoutes', () => {
@ -26,4 +28,18 @@ describe('campaignActionRoutes', () => {
it('rejects unknown wizard slugs', () => {
expect(isCampaignWizardActionSlug('inventado')).toBe(false)
})
it('builds wizard hrefs with optional municipio query', () => {
expect(wizardActionHref('atualizar-votos')).toBe(`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos`)
expect(wizardActionHref('atualizar-votos', 'cairu')).toBe(
`${CAMPAIGN_ACTIONS_HOME}/atualizar-votos?municipio=cairu`,
)
})
it('parses municipio search param', () => {
expect(parseWizardMunicipioParam('cairu')).toBe('cairu')
expect(parseWizardMunicipioParam(['cairu', 'ignored'])).toBe('cairu')
expect(parseWizardMunicipioParam(undefined)).toBeUndefined()
expect(parseWizardMunicipioParam(' ')).toBeUndefined()
})
})