Merge branch 'cursor/f71ce1d5' (B47 home search)

This commit is contained in:
Francisco Ignácio Fontoura Solla 2026-07-29 15:32:37 -03:00
commit a75fe2c88f
13 changed files with 408 additions and 20 deletions

View file

@ -8,9 +8,10 @@ alwaysApply: false
## Status
- **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).
- **B46 entregue em código (2026-07-29):** thumb-zone do Início (UX-1 chassis) — `CampaignHomeLayout` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47**. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md). Próximo: **B47** busca global.
- **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` ancora `CampaignHomeActions` na metade inferior do viewport em mobile (`min-h-full` no shell + spacer `flex-1` acima da strip; clearance existente `pb-24` + bottom nav fixa, sem `position: fixed` na strip) e no topo em `md+` (`md:min-h-0`). Slots `data-slot="home-actions"` e opcional `searchSlot` → `data-slot="home-search"` (ordem documento mobile ações→busca; desktop busca→ações via `order-1`/`order-2`/`order-3`) para **B47** ✓. Wire em `campanha/(app)/page.tsx`. Unit `campaignHomeLayout.unit.spec.ts`; e2e `campaignHomeActions` verde. Sem migration. Plano: [`posicao-botoes-acao-inicio-thumb-zone.md`](../../../docs/plans/posicao-botoes-acao-inicio-thumb-zone.md).
- **B45 entregue em código (2026-07-29):** catálogo de ações do Início por persona (UX-1 chassis) — `src/lib/campaignHomeActions.ts` (`homeActionsForRole`, `toHomeActionButtonProps`) + ilha client `CampaignHomeActions` (ícones Lucide no client; RSC passa `role` + `uncoveredMunicipalitiesHref` de `buildMunicipalityListHref`). Staff (coordinator/candidate/advisor): 6 ações; `uncovered-municipalities` → `?coverage=sem_assessor&sort=votos`; demais inertes (`cursor-default`). Leader: 2 ações; `my-contacts` → `/campanha/contatos`. Seção "O que você quer fazer?" acima da strip B44. Unit `campaignHomeActions.unit.spec.ts`; e2e `campaignHomeActions.e2e.spec.ts`. Sem migration. Plano: [`catalogo-acoes-inicio-por-persona.md`](../../../docs/plans/catalogo-acoes-inicio-por-persona.md).
- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios.
- **B46–B55 registrados no roadmap (2026-07-29):** ~~thumb-zone do Início (**B46**)~~ entregue; busca global fatiada (**B47** input ✓ → **B48** Municípios/TIs → **B49–B53** outros grupos → **B54** grid/cap → **B55** WA/share). Cadeia: B45 ✓ → B46 ✓ → B47 ✓ → …. Planos em `docs/plans/posicao-botoes-*` e `busca-global-*`. Assumido: staff-only na busca; clique = rotas de detalhe existentes; TIs no grupo Municípios.
- **B44 entregue em código (2026-07-29):** primitivo UX-1 — `CampaignHomeActionButton` (círculo `bg-muted` + ícone + label; um só controle focável) e `CampaignHomeActionStrip` (`overflow-x-auto`, `touch-pan-x`, snap). Descrição: Tooltip shadcn em ponteiro fino; long-press (~450ms) abre Drawer em ponteiro grosso (`useSyncExternalStore` + `matchMedia`, um alvo no DOM) sem disparar click (`src/lib/campaignLongPress.ts`). Montado no Início por **B45** ✓. Specs: `campaignLongPress.unit.spec.tsx`, `campaignHomeActionButton.unit.spec.tsx`. Plano: [`botao-acao-inicio-strip.md`](../../../docs/plans/botao-acao-inicio-strip.md).
- **B43 entregue em código (2026-07-29):** chassis UX-1 — `/campanha` fica só shell (`CampaignPageShell` vazio); dashboard staff move para `/campanha/quadro` (corpo idêntico ao antigo Início); `LeaderContactsPanel` em `/campanha/contatos`; nav staff ganha **Quadro** (sidebar; fora do bottom nav mobile); liderança **Início** + **Meus contatos**; `requireCampaignPageActor` redireciona leader bloqueado para `/campanha/contatos` (não para o Início blank). Sem migration. Plano: [`inicio-em-branco-quadro.md`](../../../docs/plans/inicio-em-branco-quadro.md). Desbloqueia **B44** / **B45**.
- **Sessão observada CG 2026-07-29 (Bloco B):** 1º hands-on ~54 min. Achados U1–U8 / O9–O11 — não rola; mental model **Ação→Local→Quem**; confirmações invisíveis; forms precisam ser contínuos; hit = lista sem assessor × votos 2022; mapa ≠ mesa de trabalho. Pivot: tabelas=bulk; operação=fluxos lineares tipo Nubank. Snapshot [`sessao-observada-coordenador-2026-07-29-snapshot.md`](../../../docs/plans/sessao-observada-coordenador-2026-07-29-snapshot.md); CUSTOMER.md/OST atualizados. Prazo soft: manejável até reunião assessoria **03/08**. **Chassis:** ~~B43~~ ✓ → ~~B44~~ ✓ → ~~B45~~ ✓ → ~~B46~~ ✓ → **B47–B55** busca global (wizards depois).

View file

@ -1,7 +1,7 @@
# Busca global do Início — input e modo resultados
Status: rascunho
Atualizado em: 2026-07-29
Status: entregue (2026-07-29)
Atualizado em: 2026-07-29 — as-built: `CampaignHomeStaffChrome` (client) + `CampaignHomeSearch` + `useHomeSearchQuery` + `HomeSearchContext`; contrato `src/lib/campaignHomeSearchContract.ts` (debounce 250 ms, mín. 2 chars); staff-only em `page.tsx`; modo focado esconde strip/spacer via `CampaignHomeLayout` `focused`; região de resultados vazia (hits = B48+); DOM mantém ordem B46 (`order-*`); sem endpoint; Escape limpa query. Unit `campaignHomeSearchQuery` + `campaignHomeSearch`; e2e B47 em `campaignHomeActions`. Sem migration.
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B47 — busca global)
Impeccable: C — superfície nova de busca no Início (expand/collapse + empty results region)
Appetite: ~1–1,5 dia eng; ilha client + debounce + chrome de modo focado; sem provider de hits ainda
@ -93,6 +93,13 @@ Componentes:
- **`?q=` na URL do Início** (shareable). Revisitar se mesa pedir mandar link da busca.
- **Busca na liderança (apoiadores).** Revisitar se leader pedir achar contato sem rolar a lista.
- **Contexto / boundary só com query debounced para grupos B48+** (evitar re-render por keystroke nos providers). Revisitar ao plugar o primeiro provider com trabalho não trivial em `searchResults`.
- **`useDebouncedState` compartilhado com `AsyncSearchCombobox`.** Revisitar no 3º call site client-only com o mesmo contrato de debounce.
- **Fundir wrapper `CampaignHomeStaffChrome` em `CampaignHomeLayout`.** Revisitar se `data-home-focused` ganhar CSS além de `focused` prop (B48 layout).
## Já resolvido no simplify pós-B47 (não reabrir)
- Estado `isDebouncing` derivado (`raw !== debounced`); `HomeSearchController` como tipo único; JSDoc de `focused`; `aria-busy` deduplicado no input.
## Referências

View file

@ -1,6 +1,6 @@
# Roadmap — Teqo
Atualizado em: 2026-07-29 — fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓ (thumb-zone ações no Início UX-1); B58 registrado (polimento strip); B56–B57 registrados; B47–B55 busca global fatiada.
Atualizado em: 2026-07-29 — B47 ✓ (busca global input + modo focado no Início); fill-in Cenário junto aos filtros (municípios) ✓; B46 ✓; B58 registrado; B48–B55 resultados busca.
Registro canônico dos **próximos** planos e débitos. Histórico de entregas: resumo abaixo + planos em [`docs/plans/`](plans/) + notebook [`.cursor/rules/projects/nucleos-eleitorais.mdc`](../.cursor/rules/projects/nucleos-eleitorais.mdc).
## Âncoras do calendário eleitoral 2026 (Res. TSE 23.760/2026)
@ -83,13 +83,13 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
### Demais itens abertos
- **UX-1 fluxos ação-primeiro (prioridade pós-sessão 29/07)** — evidência: [snapshot](plans/sessao-observada-coordenador-2026-07-29-snapshot.md) · O9–O11 em CUSTOMER.md · **rascunho:** [fluxos-acao-primeiro-inicio.md](plans/fluxos-acao-primeiro-inicio.md). Soft deadline: **03/08**. **Chassis:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → **B58** (polimento strip) → **B47–B55** (busca global fatiada) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes.
- **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:** ~~**B43**~~ ✓ → ~~**B44**~~ ✓ → ~~**B45**~~ ✓ → ~~**B46**~~ ✓ → ~~**B47**~~ ✓ → **B58** (polimento strip) → **B48–B55** (resultados busca) · **B56** (resumo total+cobertura no topo do Início); **B57** delta 7d = futuro/baixa prioridade; wizards com escrita = fatias seguintes.
- ~~**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) · Depende de B45 ✓ · ~0,5d · Janela 1 · Paralelizável com ~~B46~~ ✓ · Impeccable B · [plano](plans/polimento-strip-acoes-inicio.md)
- ~~**B46** Posição dos botões de ação (thumb zone)~~ — **entregue 2026-07-29** (`CampaignHomeLayout`: coluna `min-h-full` + spacer mobile; strip em `data-slot="home-actions"` na metade inferior do viewport; `md+` no topo; slot `searchSlot` + `data-slot="home-search"` para B47; sem migration) · [plano](plans/posicao-botoes-acao-inicio-thumb-zone.md)
- **B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto) · staff-only · Depende de B46 ✓ · ~1–1,5d · Janela 1 · Impeccable C · [plano](plans/busca-global-inicio-input.md)
- ~~**B47** Busca global — input (debounce, sem botão; modo focado: sobe e esconde o resto)~~ — **entregue 2026-07-29** (`CampaignHomeStaffChrome` + `CampaignHomeSearch` + `useHomeSearchQuery`; debounce 250 ms; query ativa ≥2 chars; `HomeSearchContext` para B48+; staff-only via `isStaffCampaignRole`; Escape limpa; região `aria-live` sem hits ainda; sem migration) · [plano](plans/busca-global-inicio-input.md)
- **B48** Busca — resultados Municípios (+ TIs no mesmo grupo; flag de prioridade alinhada; “2022” / agregado à direita; sem card) · Depende de B47 · ~1–1,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-municipios.md)
- **B49** Busca — resultados Lideranças · Depende de B47 · ~0,5–0,75d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-liderancas.md)
- **B50** Busca — resultados Assessores (access B19) · Depende de B47 · ~0,5d · Janela 1 · Impeccable B · [plano](plans/busca-global-resultados-assessores.md)
@ -205,7 +205,7 @@ flowchart TD
B45n["B45 ✓ Catálogo ações<br/>por persona"]
B58n["B58 Polish strip<br/>ações Início"]
B46n["B46 ✓ Thumb zone<br/>ações Início"]
B47n["B47 Busca global<br/>input + modo focado"]
B47n["B47 ✓ Busca global<br/>input + modo focado"]
B48n["B48 Busca<br/>Municípios + TIs"]
B49n["B49 Busca Lideranças"]
B50n["B50 Busca Assessores"]

View file

@ -1,5 +1,6 @@
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
import { isStaffCampaignRole } from '@/lib/campaignRoles'
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
@ -10,20 +11,26 @@ export const dynamic = 'force-dynamic'
export default async function CampaignHomePage() {
const user = await requireCampaignPageActor()
const uncoveredMunicipalitiesHref = isStaffCampaignRole(user.role)
const staff = isStaffCampaignRole(user.role)
const uncoveredMunicipalitiesHref = staff
? buildMunicipalityListHref({ page: 1, coverage: 'sem_assessor', sort: 'votos' }, 1)
: undefined
const actions = (
<CampaignHomeActions
role={user.role}
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
/>
)
return (
<CampaignPageShell aria-label="Início" className="min-h-full">
<CampaignHomeLayout
actions={
<CampaignHomeActions
role={user.role}
uncoveredMunicipalitiesHref={uncoveredMunicipalitiesHref}
/>
}
/>
{staff ? (
<CampaignHomeStaffChrome actions={actions} />
) : (
<CampaignHomeLayout actions={actions} />
)}
</CampaignPageShell>
)
}

View file

@ -1,19 +1,29 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/utils'
export const CampaignHomeLayout = ({
actions,
searchSlot,
focused = false,
}: {
actions: ReactNode
searchSlot?: ReactNode
/** When true (trimmed debounced query length ≥ min), hide thumb-zone spacer and action strip. */
focused?: boolean
}) => (
<div className="flex min-h-full w-full flex-col md:min-h-0">
<div aria-hidden className="min-h-0 flex-1 md:hidden" />
<div data-slot="home-actions" className="order-1 min-w-0 md:order-2">
{focused ? null : (
<div aria-hidden className="min-h-0 flex-1 md:hidden" data-slot="home-thumb-spacer" />
)}
<div data-slot="home-actions" className={cn('order-1 min-w-0 md:order-2', focused && 'hidden')}>
{actions}
</div>
{searchSlot ? (
<div data-slot="home-search" className="order-3 mt-4 min-w-0 md:order-1 md:mt-0 md:mb-6">
<div
data-slot="home-search"
className={cn('order-3 min-w-0 md:order-1', focused ? 'mt-0' : 'mt-4 md:mt-0 md:mb-6')}
>
{searchSlot}
</div>
) : null}

View file

@ -0,0 +1,45 @@
'use client'
import type { ReactNode } from 'react'
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
const HOME_SEARCH_INPUT_ID = 'homeSearchQuery'
const HOME_SEARCH_LABEL = 'Buscar na campanha'
export const CampaignHomeSearch = ({ children }: { children?: ReactNode }) => {
const { query, setRaw, clear, isDebouncing } = useHomeSearch()
const ariaBusy = isDebouncing || undefined
return (
<div className="flex w-full min-w-0 flex-col gap-4">
<CampaignSearchInput
id={HOME_SEARCH_INPUT_ID}
label={HOME_SEARCH_LABEL}
placeholder="Município, liderança, atividade…"
value={query.raw}
onChange={(event) => setRaw(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
clear()
}
}}
autoComplete="off"
enterKeyHint="search"
aria-busy={ariaBusy}
/>
<div
role="region"
aria-live="polite"
aria-label="Resultados da busca"
aria-busy={ariaBusy}
className="min-w-0"
data-slot="home-search-results"
>
{children}
</div>
</div>
)
}

View file

@ -0,0 +1,36 @@
'use client'
import type { ReactNode } from 'react'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { cn } from '@/lib/utils'
export const CampaignHomeStaffChrome = ({
actions,
searchResults,
}: {
actions: ReactNode
/** Result groups plug in here from B48+. */
searchResults?: ReactNode
}) => {
const searchState = useHomeSearchQuery()
const focused = searchState.query.isActive
return (
<HomeSearchProvider value={searchState}>
<div
className={cn('flex min-h-full w-full flex-col', focused && 'md:min-h-0')}
data-home-focused={focused || undefined}
>
<CampaignHomeLayout
actions={actions}
focused={focused}
searchSlot={<CampaignHomeSearch>{searchResults}</CampaignHomeSearch>}
/>
</div>
</HomeSearchProvider>
)
}

View file

@ -0,0 +1,25 @@
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import type { HomeSearchController } from '@/components/campaign/dashboard/useHomeSearchQuery'
export type HomeSearchContextValue = HomeSearchController
const HomeSearchContext = createContext<HomeSearchContextValue | null>(null)
export const HomeSearchProvider = ({
value,
children,
}: {
value: HomeSearchContextValue
children: ReactNode
}) => <HomeSearchContext.Provider value={value}>{children}</HomeSearchContext.Provider>
export const useHomeSearch = (): HomeSearchContextValue => {
const ctx = useContext(HomeSearchContext)
if (!ctx) {
throw new Error('useHomeSearch must be used within HomeSearchProvider')
}
return ctx
}

View file

@ -0,0 +1,61 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
HOME_SEARCH_DEBOUNCE_MS,
homeSearchQueryIsActive,
normalizeHomeSearchRaw,
type HomeSearchQuery,
} from '@/lib/campaignHomeSearchContract'
export type HomeSearchController = {
query: HomeSearchQuery
setRaw: (next: string) => void
clear: () => void
isDebouncing: boolean
}
export const useHomeSearchQuery = (): HomeSearchController => {
const [raw, setRaw] = useState('')
const [debounced, setDebounced] = useState('')
useEffect(() => {
if (raw === debounced) {
return
}
const handle = window.setTimeout(() => {
setDebounced(raw)
}, HOME_SEARCH_DEBOUNCE_MS)
return () => {
window.clearTimeout(handle)
}
}, [raw, debounced])
const trimmedDebounced = normalizeHomeSearchRaw(debounced)
const isDebouncing = raw !== debounced
const query = useMemo(
(): HomeSearchQuery => ({
raw,
debounced: trimmedDebounced,
isActive: homeSearchQueryIsActive(trimmedDebounced),
}),
[raw, trimmedDebounced],
)
const clear = useCallback(() => {
setRaw('')
setDebounced('')
}, [])
return useMemo(
() => ({
query,
setRaw,
clear,
isDebouncing,
}),
[query, clear, isDebouncing],
)
}

View file

@ -0,0 +1,16 @@
/** Debounce for Início omnibox — shorter than list search (1000 ms RSC round-trips). */
export const HOME_SEARCH_DEBOUNCE_MS = 250
/** Minimum trimmed length before focused mode and result providers run. */
const HOME_SEARCH_MIN_QUERY_LENGTH = 2
export type HomeSearchQuery = {
raw: string
debounced: string
isActive: boolean
}
export const homeSearchQueryIsActive = (trimmedDebounced: string): boolean =>
trimmedDebounced.length >= HOME_SEARCH_MIN_QUERY_LENGTH
export const normalizeHomeSearchRaw = (raw: string): string => raw.trim()

View file

@ -9,6 +9,45 @@ const staffActionLabels = [
'Ver quem ainda não está coberto',
] as const
test.describe('Início — busca global (B47)', () => {
test('staff sees campaign search on home', 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 expect(page.getByLabel('Buscar na campanha')).toBeVisible()
})
test('leader does not see campaign search on home', async ({ campaign, page }) => {
const { fixtures } = campaign
const leader = await fixtures.createCampaignUser('leader', {
name: fixtures.value('Liderança'),
})
await campaign.login(page, leader.email!, leader.password)
await expect(page.getByLabel('Buscar na campanha')).toHaveCount(0)
})
test('staff focused search hides action strip after debounce', 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)
const heading = page.getByRole('heading', { name: 'O que você quer fazer?', exact: true })
await expect(heading).toBeVisible()
await page.getByLabel('Buscar na campanha').fill('ca')
await expect(heading).toBeHidden({ timeout: 5000 })
await page.getByLabel('Buscar na campanha').fill('')
await expect(heading).toBeVisible({ timeout: 5000 })
})
})
test.describe('Início — catálogo de ações (B45)', () => {
test('staff sees six home actions and can open municipalities without coverage', async ({
campaign,

View file

@ -0,0 +1,40 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
import { CampaignHomeSearch } from '@/components/campaign/dashboard/CampaignHomeSearch'
import { HomeSearchProvider } from '@/components/campaign/dashboard/HomeSearchContext'
const providerValue = {
query: { raw: 'ca', debounced: 'ca', isActive: true },
setRaw: vi.fn(),
clear: vi.fn(),
isDebouncing: false,
}
describe('CampaignHomeLayout focused mode', () => {
it('hides actions and thumb spacer when focused', () => {
const { container } = render(
<CampaignHomeLayout focused actions={<p>Actions block</p>} searchSlot={<p>Search</p>} />,
)
expect(container.querySelector('[data-slot="home-thumb-spacer"]')).toBeNull()
expect(container.querySelector('[data-slot="home-actions"]')?.className).toContain('hidden')
})
})
describe('CampaignHomeSearch', () => {
it('renders search input and results region', () => {
render(
<HomeSearchProvider value={providerValue}>
<CampaignHomeSearch>
<p>Group stub</p>
</CampaignHomeSearch>
</HomeSearchProvider>,
)
expect(screen.getByLabelText('Buscar na campanha')).toBeTruthy()
expect(screen.getByRole('region', { name: 'Resultados da busca' })).toBeTruthy()
expect(screen.getByText('Group stub')).toBeTruthy()
})
})

View file

@ -0,0 +1,101 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { HOME_SEARCH_DEBOUNCE_MS } from '@/lib/campaignHomeSearchContract'
describe('useHomeSearchQuery', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
})
it('debounces raw input before updating debounced and isActive', () => {
const { result } = renderHook(() => useHomeSearchQuery())
act(() => {
result.current.setRaw('ca')
})
expect(result.current.query.debounced).toBe('')
expect(result.current.query.isActive).toBe(false)
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.debounced).toBe('ca')
expect(result.current.query.isActive).toBe(true)
})
it('does not activate until trimmed debounced length meets minimum', () => {
const { result } = renderHook(() => useHomeSearchQuery())
act(() => {
result.current.setRaw('a')
})
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.isActive).toBe(false)
act(() => {
result.current.setRaw('ab')
})
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.isActive).toBe(true)
})
it('trims debounced value and treats whitespace-only as inactive', () => {
const { result } = renderHook(() => useHomeSearchQuery())
act(() => {
result.current.setRaw(' ca ')
})
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.debounced).toBe('ca')
expect(result.current.query.isActive).toBe(true)
act(() => {
result.current.setRaw(' ')
})
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.debounced).toBe('')
expect(result.current.query.isActive).toBe(false)
})
it('clear resets raw, debounced, and debouncing state', () => {
const { result } = renderHook(() => useHomeSearchQuery())
act(() => {
result.current.setRaw('cairu')
})
act(() => {
vi.advanceTimersByTime(HOME_SEARCH_DEBOUNCE_MS)
})
expect(result.current.query.isActive).toBe(true)
act(() => {
result.current.clear()
})
expect(result.current.query.raw).toBe('')
expect(result.current.query.debounced).toBe('')
expect(result.current.query.isActive).toBe(false)
expect(result.current.isDebouncing).toBe(false)
})
})