diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc
index 1b09d8bf..555cad8d 100644
--- a/.cursor/rules/projects/nucleos-eleitorais.mdc
+++ b/.cursor/rules/projects/nucleos-eleitorais.mdc
@@ -7,6 +7,7 @@ alwaysApply: false
## Status
+- **B52 entregue em código (2026-07-29):** quarto grupo da busca global do Início — **Dobradinhas** via `searchHomeStateDeputies` + `HomeSearchStateDeputyGroup` (após Assessores); `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: **B51** atividades.
- **B72 entregue em código (2026-07-29):** gap horizontal da strip de ações do Início (UX-1) — `CampaignHomeActionStrip` `
` `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.
- **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/` (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**.
@@ -15,7 +16,7 @@ alwaysApply: false
- **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).
- **Fill-in Cenário junto aos filtros entregue em código (2026-07-29):** em `/campanha/municipios` (staff), o seletor **Cenário** (`VoteEstimateScenarioField`) saiu do header do `MunicipalityListOverview` e entrou na barra slim de `MunicipalityFilters` (desktop: antes de Salvar/Limpar; mobile: após filtros staff, antes de Ordenar). `MunicipalityEstimateScenarioProvider` passou a envolver filtros + resultados; cenário continua estado local (fora da URL e do **Limpar**). Hint `VOTE_ESTIMATE_SCENARIO_FILTERS_HINT`. Sem migration/action/Consent. Plano: [`cenario-junto-filtros-municipios.md`](../../../docs/plans/cenario-junto-filtros-municipios.md).
- **B49 entregue em código (2026-07-29):** segundo grupo da busca global do Início — **Lideranças** via `searchHomeLeaderships` + `HomeSearchLeadershipGroup` (entre Municípios e Assessores); payload `leaderships` no mesmo `POST /campanha/home-search`; empty state considera todos os grupos (`homeSearchHasAnyHits`); linha → `/campanha/liderancas/[id]`; `HomeSearchHitRow` compartilhado. Plano: [`busca-global-resultados-liderancas.md`](../../../docs/plans/busca-global-resultados-liderancas.md). Próximo: **B51** atividades.
-- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md).
+- **B50 entregue em código (2026-07-29):** grupo **Assessores** na busca global do Início — `searchHomeAdvisors` (só CG/candidato; assessor recebe `[]`); match word-start no nome; secundário = N municípios; `HomeSearchAdvisorGroup` após Lideranças; `municipalityIdsByAdvisorIds` exportado de `advisorData`. Int `homeSearchAdvisors.int.spec.ts`. WA = **B55**. Plano: [`busca-global-resultados-assessores.md`](../../../docs/plans/busca-global-resultados-assessores.md). ~~Próximo: **B51** atividades.~~ **B52** ✓ dobradinhas; **B51** atividades pendente.
- **B48 entregue em código (2026-07-29):** primeiro provider da busca global do Início — grupo **Municípios** (municípios scoped + TIs no mesmo bloco) via `POST /campanha/home-search`, `searchHomeMunicipalities`, `HomeSearchMunicipalityGroup` no slot B47; readout 2022 alinhado à lista (`MunicipalityVotePositionReadout`). **B54** grid/cap. Plano: [`busca-global-resultados-municipios.md`](../../../docs/plans/busca-global-resultados-municipios.md).
- **B47 entregue em código (2026-07-29):** busca global do Início (UX-1) — input staff-only com debounce 250 ms (`HOME_SEARCH_DEBOUNCE_MS`), sem botão Pesquisar; modo focado quando query debounced trim ≥2 chars esconde strip B45 e spacer thumb-zone (`CampaignHomeLayout` `focused`). Ilha `CampaignHomeStaffChrome` + `CampaignHomeSearch` + `HomeSearchContext` / `useHomeSearch` para grupos B48+; contrato em `src/lib/campaignHomeSearchContract.ts`. Leader continua sem omnibox (`CampaignHomeLayout` seco). Escape limpa. Sem migration. Plano: [`busca-global-inicio-input.md`](../../../docs/plans/busca-global-inicio-input.md). ~~Próximo: **B48** resultados Municípios.~~
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` com slots `data-slot="home-actions"` / `home-search` (ordem mobile ações→busca; desktop busca→ações). Âncora inferior corrigida por **B65** ✓. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).
diff --git a/docs/plans/busca-global-resultados-dobradinhas.md b/docs/plans/busca-global-resultados-dobradinhas.md
index 21e772ee..948780f1 100644
--- a/docs/plans/busca-global-resultados-dobradinhas.md
+++ b/docs/plans/busca-global-resultados-dobradinhas.md
@@ -1,7 +1,7 @@
# Busca global — resultados Dobradinhas
-Status: rascunho
-Atualizado em: 2026-07-29
+Status: entregue (2026-07-29)
+Atualizado em: 2026-07-29 — as-built: `searchHomeStateDeputies` (staff; word-start em nome ou partido; cap 25; `municipalityIdsByStateDeputyIds` exportado de `stateDeputyData`); `HomeSearchStateDeputyHit` + campo `stateDeputies` no `POST /campanha/home-search`; `HomeSearchStateDeputyGroup` após Assessores; secundário = partido · N municípios; sem WA (B55 — entidade sem telefone); int `homeSearchStateDeputies.int.spec.ts`.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B52 — busca global)
Impeccable: B — grupo de hits no slot B47
Appetite: ~0,5 dia eng; loader + grupo; WA = B55
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 3c79ed17..e4400b83 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,6 @@
# Roadmap — Teqo
-Atualizado em: 2026-07-29 — ~~**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**~~ ✓; B51–B55; wizards B60–B64.
+Atualizado em: 2026-07-29 — ~~**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**~~ ✓; B51–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,7 +83,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
-- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis Início:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → ~~**B58**~~ ✓ → ~~**B67**~~ ✓ (pan strip) → ~~**B65**~~ ✓ (âncora inferior mobile) → **B66** (focus limpa tela + animação) → ~~**B48**~~ ✓ → ~~**B49**~~ ✓ → ~~**B68**~~ ✓ → **B71** (polish resultados busca) · ~~**B72**~~ ✓ (gap strip) → ~~**B50**~~ ✓ → **B51–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**~~ ✓ → ~~**B52**~~ ✓ → **B51** · **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)
@@ -108,7 +108,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- ~~**B49** Busca — resultados Lideranças~~ — **entregue 2026-07-29** (`searchHomeLeaderships`, `HomeSearchLeadershipGroup`, `leaderships` no POST `/campanha/home-search`; word-start; assessor scoped; municípios no secundário; cap 25; sem WA — **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-liderancas.md)
- ~~**B50** Busca — resultados Assessores (access B19)~~ — **entregue 2026-07-29** (`searchHomeAdvisors` + `HomeSearchAdvisorGroup`; unrestricted only; word-start no nome; contagem de municípios na linha; `POST /campanha/home-search` agrega `advisors`) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-assessores.md)
- **B51** Busca — resultados Atividades · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-atividades.md)
-- **B52** Busca — resultados Dobradinhas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-dobradinhas.md)
+- **B52** ~~Busca — resultados Dobradinhas~~ — **entregue 2026-07-29** (`searchHomeStateDeputies` + `HomeSearchStateDeputyGroup`; staff incl. assessor; word-start em nome ou partido; secundário partido · contagem de municípios; `POST /campanha/home-search` agrega `stateDeputies`; WA = **B55**) · Depende de B47 ✓ · [plano](plans/busca-global-resultados-dobradinhas.md)
- **B53** Busca — resultados Demandas · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-demandas.md)
- **B54** Busca — layout resultados (mobile empilhado; tablet 2 col; desktop 3 col; cap ao viewport; prioridade Municípios) · Depende de B47 · soft B48 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-layout-resultados.md)
- **B55** Busca — ações de linha (WA em lideranças/assessores/dobradinhas; share nativo mobile / WA no desktop em demandas/atividades) · Depende de B47 + ≥1 de B49–B53 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-acoes-linha.md)
diff --git a/src/app/(campaign)/campanha/(app)/home-search/route.ts b/src/app/(campaign)/campanha/(app)/home-search/route.ts
index d3312428..6bb4792d 100644
--- a/src/app/(campaign)/campanha/(app)/home-search/route.ts
+++ b/src/app/(campaign)/campanha/(app)/home-search/route.ts
@@ -12,6 +12,7 @@ import { loadHomeSearchSuggestions } from '@/utilities/homeSearch/loadHomeSearch
import { searchHomeAdvisors } from '@/utilities/homeSearch/searchHomeAdvisors'
import { searchHomeLeaderships } from '@/utilities/homeSearch/searchHomeLeaderships'
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
+import { searchHomeStateDeputies } from '@/utilities/homeSearch/searchHomeStateDeputies'
export const dynamic = 'force-dynamic'
@@ -29,15 +30,17 @@ export const POST = campaignJsonMutationRoute(
return NextResponse.json(result)
}
- const [municipalityResult, advisors, leaderships] = await Promise.all([
+ const [municipalityResult, advisors, leaderships, stateDeputies] = await Promise.all([
searchHomeMunicipalities(payload, actor, body.query),
searchHomeAdvisors(payload, actor, body.query),
searchHomeLeaderships(payload, actor, body.query),
+ searchHomeStateDeputies(payload, actor, body.query),
])
return NextResponse.json({
...municipalityResult,
advisors,
leaderships,
+ stateDeputies,
})
},
)
diff --git a/src/app/(campaign)/campanha/(app)/page.tsx b/src/app/(campaign)/campanha/(app)/page.tsx
index 1455e32f..3c6cde5c 100644
--- a/src/app/(campaign)/campanha/(app)/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/page.tsx
@@ -10,6 +10,7 @@ import { HomeSearchAdvisorGroup } from '@/components/campaign/dashboard/HomeSear
import { HomeSearchLeadershipGroup } from '@/components/campaign/dashboard/HomeSearchLeadershipGroup'
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
+import { HomeSearchStateDeputyGroup } from '@/components/campaign/dashboard/HomeSearchStateDeputyGroup'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { loadCampaignHomeSummary } from '@/utilities/campaignDashboardData'
@@ -50,6 +51,7 @@ export default async function CampaignHomePage() {
+
}
summarySlot={summarySlot}
diff --git a/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx b/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
index a283fe2b..17b1cf55 100644
--- a/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
+++ b/src/components/campaign/dashboard/HomeSearchAdvisorGroup.tsx
@@ -2,11 +2,7 @@
import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
-
-const formatMunicipalityCount = (count: number): string => {
- if (count === 1) return '1 município'
- return `${count} municípios`
-}
+import { formatHomeSearchMunicipalityCount } from '@/lib/campaignHomeSearchHits'
export const HomeSearchAdvisorGroup = () => {
const { results } = useHomeSearchResults()
@@ -27,7 +23,7 @@ export const HomeSearchAdvisorGroup = () => {
))}
diff --git a/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx b/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx
new file mode 100644
index 00000000..0a01bd01
--- /dev/null
+++ b/src/components/campaign/dashboard/HomeSearchStateDeputyGroup.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+import { HomeSearchHitRow } from '@/components/campaign/dashboard/HomeSearchHitRow'
+import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
+import {
+ formatHomeSearchMunicipalityCount,
+ homeSearchStateDeputyGroupHasHits,
+} from '@/lib/campaignHomeSearchHits'
+
+const formatStateDeputySecondary = (
+ party: string | null,
+ municipalityCount: number,
+): string | undefined => {
+ const parts: string[] = []
+ if (party) parts.push(party)
+ if (municipalityCount > 0) parts.push(formatHomeSearchMunicipalityCount(municipalityCount))
+ return parts.length > 0 ? parts.join(' · ') : undefined
+}
+
+export const HomeSearchStateDeputyGroup = () => {
+ const { results } = useHomeSearchResults()
+
+ if (results.status !== 'success') return null
+
+ const { stateDeputies } = results.data
+ if (!homeSearchStateDeputyGroupHasHits(results.data)) return null
+
+ return (
+
+
+ Dobradinhas
+
+
+ {stateDeputies.map((hit) => (
+ -
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/lib/campaignHomeSearchContract.ts b/src/lib/campaignHomeSearchContract.ts
index cb0ed851..67e3ec55 100644
--- a/src/lib/campaignHomeSearchContract.ts
+++ b/src/lib/campaignHomeSearchContract.ts
@@ -4,6 +4,9 @@ export const HOME_SEARCH_DEBOUNCE_MS = 250
/** Minimum trimmed length before focused mode and result providers run. */
export const HOME_SEARCH_MIN_QUERY_LENGTH = 2
+/** Cap for capped home-search entity groups (leadership, dobradinhas). */
+export const HOME_SEARCH_RESULT_HIT_CAP = 25
+
export type HomeSearchQuery = {
raw: string
debounced: string
diff --git a/src/lib/campaignHomeSearchHits.ts b/src/lib/campaignHomeSearchHits.ts
index e803103b..e797897f 100644
--- a/src/lib/campaignHomeSearchHits.ts
+++ b/src/lib/campaignHomeSearchHits.ts
@@ -32,6 +32,13 @@ export type HomeSearchLeadershipHit = {
municipalitiesSummary: string
}
+export type HomeSearchStateDeputyHit = {
+ slug: string
+ name: string
+ party: string | null
+ municipalityCount: number
+}
+
type HomeSearchResultKind = 'search' | 'suggest'
export type HomeSearchSuccessResponse = {
@@ -41,6 +48,7 @@ export type HomeSearchSuccessResponse = {
territories: HomeSearchTerritoryHit[]
advisors: HomeSearchAdvisorHit[]
leaderships: HomeSearchLeadershipHit[]
+ stateDeputies: HomeSearchStateDeputyHit[]
}
export const homeSearchMunicipalityGroupHasHits = (
@@ -51,6 +59,15 @@ export const homeSearchLeadershipGroupHasHits = (
data: Pick,
): boolean => data.leaderships.length > 0
+export const homeSearchStateDeputyGroupHasHits = (
+ data: Pick,
+): boolean => data.stateDeputies.length > 0
+
+export const formatHomeSearchMunicipalityCount = (count: number): string => {
+ if (count === 1) return '1 município'
+ return `${count} municípios`
+}
+
export const toHomeSearchMunicipalityHit = (doc: {
slug: string
name: string
@@ -68,4 +85,5 @@ export const toHomeSearchMunicipalityHit = (doc: {
export const homeSearchHasAnyHits = (data: HomeSearchSuccessResponse): boolean =>
homeSearchMunicipalityGroupHasHits(data) ||
data.advisors.length > 0 ||
- homeSearchLeadershipGroupHasHits(data)
+ homeSearchLeadershipGroupHasHits(data) ||
+ homeSearchStateDeputyGroupHasHits(data)
diff --git a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts
index aac390c9..179bf7b8 100644
--- a/src/utilities/homeSearch/loadHomeSearchSuggestions.ts
+++ b/src/utilities/homeSearch/loadHomeSearchSuggestions.ts
@@ -81,5 +81,6 @@ export const loadHomeSearchSuggestions = async (
territories: [],
advisors: [],
leaderships: [],
+ stateDeputies: [],
}
}
diff --git a/src/utilities/homeSearch/searchHomeAdvisors.ts b/src/utilities/homeSearch/searchHomeAdvisors.ts
index 1f083b53..f2562d41 100644
--- a/src/utilities/homeSearch/searchHomeAdvisors.ts
+++ b/src/utilities/homeSearch/searchHomeAdvisors.ts
@@ -29,6 +29,7 @@ export const searchHomeAdvisors = async (
const normalizedQuery = normalizeHomeSearchName(query)
+ // Intentional admin bypass — unrestricted staff gate already ran in the route.
const result = await payload.find({
collection: 'campaignUser',
where: { role: { equals: 'advisor' } },
diff --git a/src/utilities/homeSearch/searchHomeLeaderships.ts b/src/utilities/homeSearch/searchHomeLeaderships.ts
index 9989b053..a913445d 100644
--- a/src/utilities/homeSearch/searchHomeLeaderships.ts
+++ b/src/utilities/homeSearch/searchHomeLeaderships.ts
@@ -2,7 +2,11 @@ import 'server-only'
import type { Payload } from 'payload'
-import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
+import {
+ HOME_SEARCH_RESULT_HIT_CAP,
+ homeSearchQueryIsActive,
+ normalizeHomeSearchRaw,
+} from '@/lib/campaignHomeSearchContract'
import type { HomeSearchLeadershipHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
@@ -16,8 +20,6 @@ import type { CampaignUser, Leadership } from '@/payload-types'
import { truncatedNamesLabel } from '@/utilities/campaignListUrl'
import { buildLeadershipListWhere } from '@/utilities/leadership/leadershipListUrl'
-const HOME_SEARCH_LEADERSHIP_HIT_CAP = 25
-
const municipalityNamesFromLeadership = (doc: Leadership): string[] => {
const names = new Set()
for (const municipality of doc.municipalities ?? []) {
@@ -88,7 +90,7 @@ export const searchHomeLeaderships = async (
return candidates
.sort((left, right) => compareHomeSearchNameRelevance(left, right, normalizedQuery))
- .slice(0, HOME_SEARCH_LEADERSHIP_HIT_CAP)
+ .slice(0, HOME_SEARCH_RESULT_HIT_CAP)
.map(
(row): HomeSearchLeadershipHit => ({
kind: 'leadership',
diff --git a/src/utilities/homeSearch/searchHomeMunicipalities.ts b/src/utilities/homeSearch/searchHomeMunicipalities.ts
index b34df2b3..41b01937 100644
--- a/src/utilities/homeSearch/searchHomeMunicipalities.ts
+++ b/src/utilities/homeSearch/searchHomeMunicipalities.ts
@@ -28,6 +28,7 @@ const emptyResponse = (): HomeSearchSuccessResponse => ({
territories: [],
advisors: [],
leaderships: [],
+ stateDeputies: [],
})
export const searchHomeMunicipalities = async (
@@ -114,5 +115,6 @@ export const searchHomeMunicipalities = async (
territories: territoryHits,
advisors: [],
leaderships: [],
+ stateDeputies: [],
}
}
diff --git a/src/utilities/homeSearch/searchHomeStateDeputies.ts b/src/utilities/homeSearch/searchHomeStateDeputies.ts
new file mode 100644
index 00000000..09ec52e3
--- /dev/null
+++ b/src/utilities/homeSearch/searchHomeStateDeputies.ts
@@ -0,0 +1,116 @@
+import 'server-only'
+
+import type { Payload } from 'payload'
+
+import {
+ HOME_SEARCH_RESULT_HIT_CAP,
+ homeSearchQueryIsActive,
+ normalizeHomeSearchRaw,
+} from '@/lib/campaignHomeSearchContract'
+import type { HomeSearchStateDeputyHit } 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 { municipalityIdsByStateDeputyIds } from '@/utilities/stateDeputyData'
+
+export const searchHomeStateDeputies = async (
+ payload: Payload,
+ user: CampaignUser,
+ rawQuery: string,
+): Promise => {
+ if (!isStaffCampaignRole(user.role)) {
+ throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
+ }
+
+ const query = normalizeHomeSearchRaw(rawQuery)
+ if (!homeSearchQueryIsActive(query)) {
+ return []
+ }
+
+ const normalizedQuery = normalizeHomeSearchName(query)
+
+ // List search uses `contains` on name; home search also pre-filters party
+ // then refines to word-start in memory (B49 precedent).
+ const result = await payload.find({
+ collection: 'stateDeputy',
+ where: {
+ or: [{ name: { contains: query } }, { party: { contains: query } }],
+ },
+ depth: 0,
+ limit: 0,
+ pagination: false,
+ sort: 'name',
+ select: { name: true, slug: true, party: true },
+ user,
+ overrideAccess: false,
+ })
+
+ const candidates: Array<{
+ normalizedName: string
+ slug: string
+ name: string
+ party: string | null
+ id: number
+ }> = []
+
+ for (const doc of result.docs) {
+ const normalizedName = normalizeHomeSearchName(doc.name)
+ const normalizedParty = doc.party ? normalizeHomeSearchName(doc.party) : ''
+ const nameMatch = matchesNormalizedAtWordStart(normalizedName, normalizedQuery)
+ const partyMatch =
+ normalizedParty.length > 0 && matchesNormalizedAtWordStart(normalizedParty, normalizedQuery)
+ if (!nameMatch && !partyMatch) continue
+
+ candidates.push({
+ normalizedName: nameMatch ? normalizedName : normalizedParty,
+ slug: doc.slug,
+ name: doc.name,
+ party: doc.party ?? null,
+ id: doc.id,
+ })
+ }
+
+ if (candidates.length === 0) {
+ return []
+ }
+
+ const municipalityIdsByDeputy = await municipalityIdsByStateDeputyIds(
+ payload,
+ candidates.map((row) => row.id),
+ )
+
+ return candidates
+ .map((row) => {
+ const municipalityCount = municipalityIdsByDeputy.get(row.id)?.length ?? 0
+ return {
+ normalizedName: row.normalizedName,
+ tieBreakDesc: municipalityCount,
+ hit: {
+ slug: row.slug,
+ name: row.name,
+ party: row.party,
+ municipalityCount,
+ },
+ }
+ })
+ .sort((left, right) =>
+ compareHomeSearchNameRelevance(
+ {
+ normalizedName: left.normalizedName,
+ tieBreakDesc: left.tieBreakDesc,
+ },
+ {
+ normalizedName: right.normalizedName,
+ tieBreakDesc: right.tieBreakDesc,
+ },
+ normalizedQuery,
+ ),
+ )
+ .slice(0, HOME_SEARCH_RESULT_HIT_CAP)
+ .map((row) => row.hit)
+}
diff --git a/src/utilities/stateDeputyData.ts b/src/utilities/stateDeputyData.ts
index 1cfece00..d438bf08 100644
--- a/src/utilities/stateDeputyData.ts
+++ b/src/utilities/stateDeputyData.ts
@@ -33,6 +33,42 @@ export type StateDeputyListFilterFacets = {
hasNoParty: boolean
}
+/**
+ * Municipality ids per state deputy for the LIST / home search — aggregate
+ * only, same admin bypass as `loadStateDeputyListPageData`.
+ */
+export const municipalityIdsByStateDeputyIds = async (
+ payload: Payload,
+ stateDeputyIDs: number[],
+): Promise