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:
parent
fe3d1d8bf6
commit
f6b73b3e63
20 changed files with 513 additions and 113 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <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`
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
136
src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
Normal file
136
src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,3 +100,8 @@ export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =
|
|||
homeSearchLeadershipGroupHasHits(data) ||
|
||||
homeSearchStateDeputyGroupHasHits(data) ||
|
||||
homeSearchActivityGroupHasHits(data)
|
||||
|
||||
export type WizardMunicipalitySearchSuccessResponse = {
|
||||
status: 'success'
|
||||
municipalities: HomeSearchMunicipalityHit[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
63
src/utilities/homeSearch/searchStaffMunicipalityHits.ts
Normal file
63
src/utilities/homeSearch/searchStaffMunicipalityHits.ts
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
71
tests/int/searchStaffMunicipalityHits.int.spec.ts
Normal file
71
tests/int/searchStaffMunicipalityHits.int.spec.ts
Normal 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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue