mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:43:06 +00:00
B48: home search results for municipalities and identity territories.
Staff Início posts to /campanha/home-search with scoped word-start matches, shared readout component, and simplify hardening (messages, fetch busy, malformed response guard).
This commit is contained in:
parent
cb38e26d8a
commit
05072547cd
22 changed files with 680 additions and 45 deletions
|
|
@ -8,7 +8,8 @@ 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).
|
||||
- **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.
|
||||
- **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`). Próximo: **B49** lideranças (mesmo payload); **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` 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.
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
# Busca global — resultados Municípios (+ Territórios)
|
||||
|
||||
Status: rascunho
|
||||
Atualizado em: 2026-07-29
|
||||
Status: entregue (2026-07-29)
|
||||
Atualizado em: 2026-07-29 — as-built: `POST /campanha/home-search` (`campaignJsonMutationRoute` + `homeSearchBodySchema`); loader `searchHomeMunicipalities` (`loadMunicipalityScope` + `matchesAtWordStart` + A11 readout; TIs no mesmo grupo via `loadTerritoryOverview` + votos 2022); client `HomeSearchResultsContext` (fetch único, extensível B49+) + `HomeSearchResultsShell` + `HomeSearchMunicipalityGroup`; `MunicipalityVotePositionReadout` extraído da lista; empty “Nenhum resultado.”; sem migration.
|
||||
Item do roadmap: [docs/roadmap.md](../roadmap.md) (Trilha B, item B48 — busca global)
|
||||
Impeccable: B — grupo de resultados no chrome B47; linhas sem card
|
||||
Appetite: ~1–1,5 dia eng; loader scoped + UI do grupo; 1º provider da busca
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
26
src/app/(campaign)/campanha/(app)/home-search/route.ts
Normal file
26
src/app/(campaign)/campanha/(app)/home-search/route.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
|
||||
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
|
||||
import {
|
||||
HOME_SEARCH_GENERIC_ERROR_MESSAGE,
|
||||
HOME_SEARCH_STAFF_ONLY_MESSAGE,
|
||||
} from '@/lib/campaignHomeSearchMessages'
|
||||
import { homeSearchBodySchema } from '@/lib/schemas/homeSearch'
|
||||
import { getCampaignActionContext } from '@/utilities/campaignActionContext'
|
||||
import { campaignJsonMutationRoute } from '@/utilities/campaignJsonMutationRoute'
|
||||
import { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export const POST = campaignJsonMutationRoute(
|
||||
{
|
||||
bodySchema: homeSearchBodySchema,
|
||||
safeMessages: [HOME_SEARCH_STAFF_ONLY_MESSAGE],
|
||||
genericMessage: HOME_SEARCH_GENERIC_ERROR_MESSAGE,
|
||||
},
|
||||
async ({ query }) => {
|
||||
const { payload, actor } = await getCampaignActionContext()
|
||||
const result = await searchHomeMunicipalities(payload, actor, query)
|
||||
return NextResponse.json<HomeSearchSuccessResponse>(result)
|
||||
},
|
||||
)
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
import { CampaignHomeActions } from '@/components/campaign/dashboard/CampaignHomeActions'
|
||||
import { CampaignHomeLayout } from '@/components/campaign/dashboard/CampaignHomeLayout'
|
||||
import { CampaignHomeStaffChrome } from '@/components/campaign/dashboard/CampaignHomeStaffChrome'
|
||||
import { HomeSearchMunicipalityGroup } from '@/components/campaign/dashboard/HomeSearchMunicipalityGroup'
|
||||
import { HomeSearchResultsShell } from '@/components/campaign/dashboard/HomeSearchResultsShell'
|
||||
import { CampaignPageShell } from '@/components/campaign/shell/CampaignPageShell'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import { requireCampaignPageActor } from '@/utilities/campaignPageActor'
|
||||
|
|
@ -27,7 +29,14 @@ export default async function CampaignHomePage() {
|
|||
return (
|
||||
<CampaignPageShell aria-label="Início" className="min-h-full">
|
||||
{staff ? (
|
||||
<CampaignHomeStaffChrome actions={actions} />
|
||||
<CampaignHomeStaffChrome
|
||||
actions={actions}
|
||||
searchResults={
|
||||
<HomeSearchResultsShell>
|
||||
<HomeSearchMunicipalityGroup />
|
||||
</HomeSearchResultsShell>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<CampaignHomeLayout actions={actions} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,13 +4,20 @@ import type { ReactNode } from 'react'
|
|||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
|
||||
import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch'
|
||||
|
||||
const HOME_SEARCH_INPUT_ID = 'homeSearchQuery'
|
||||
const HOME_SEARCH_LABEL = 'Buscar na campanha'
|
||||
|
||||
export const CampaignHomeSearch = ({ children }: { children?: ReactNode }) => {
|
||||
export const CampaignHomeSearch = ({
|
||||
children,
|
||||
resultsBusy = false,
|
||||
}: {
|
||||
children?: ReactNode
|
||||
resultsBusy?: boolean
|
||||
}) => {
|
||||
const { query, setRaw, clear, isDebouncing } = useHomeSearch()
|
||||
const ariaBusy = isDebouncing || undefined
|
||||
const ariaBusy = isDebouncing || resultsBusy || undefined
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 flex-col gap-4">
|
||||
|
|
@ -29,6 +36,7 @@ export const CampaignHomeSearch = ({ children }: { children?: ReactNode }) => {
|
|||
autoComplete="off"
|
||||
enterKeyHint="search"
|
||||
aria-busy={ariaBusy}
|
||||
maxLength={HOME_SEARCH_QUERY_MAX_LENGTH}
|
||||
/>
|
||||
<div
|
||||
role="region"
|
||||
|
|
|
|||
|
|
@ -5,9 +5,25 @@ 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 {
|
||||
HomeSearchResultsProvider,
|
||||
useHomeSearchResultsState,
|
||||
} from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const CampaignHomeStaffSearchSlot = ({ searchResults }: { searchResults: ReactNode }) => {
|
||||
const searchResultsState = useHomeSearchResultsState()
|
||||
|
||||
return (
|
||||
<CampaignHomeSearch resultsBusy={searchResultsState.isFetching}>
|
||||
<HomeSearchResultsProvider value={searchResultsState}>
|
||||
{searchResults}
|
||||
</HomeSearchResultsProvider>
|
||||
</CampaignHomeSearch>
|
||||
)
|
||||
}
|
||||
|
||||
export const CampaignHomeStaffChrome = ({
|
||||
actions,
|
||||
searchResults,
|
||||
|
|
@ -28,7 +44,13 @@ export const CampaignHomeStaffChrome = ({
|
|||
<CampaignHomeLayout
|
||||
actions={actions}
|
||||
focused={focused}
|
||||
searchSlot={<CampaignHomeSearch>{searchResults}</CampaignHomeSearch>}
|
||||
searchSlot={
|
||||
searchResults ? (
|
||||
<CampaignHomeStaffSearchSlot searchResults={searchResults} />
|
||||
) : (
|
||||
<CampaignHomeSearch />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</HomeSearchProvider>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,100 @@
|
|||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
import { formatElectionNumber } from '@/lib/electionFormat'
|
||||
import { buildTerritoryPageHref } from '@/lib/territoryAnchor'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const PRIORITY_ICON_SLOT_CLASS = 'flex w-4 shrink-0 justify-center'
|
||||
|
||||
const HomeSearchHitRow = ({
|
||||
href,
|
||||
primary,
|
||||
secondary,
|
||||
trailing,
|
||||
showPriority,
|
||||
}: {
|
||||
href: string
|
||||
primary: string
|
||||
secondary?: string
|
||||
trailing: ReactNode
|
||||
showPriority: boolean
|
||||
}) => (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex min-h-11 items-center gap-3 py-2.5 text-foreground',
|
||||
'rounded-md outline-none hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
||||
)}
|
||||
>
|
||||
<span className={PRIORITY_ICON_SLOT_CLASS} aria-hidden={!showPriority}>
|
||||
{showPriority ? <MunicipalityPriorityIndicator /> : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">{primary}</span>
|
||||
{secondary ? (
|
||||
<span className="block truncate text-xs text-muted-foreground">{secondary}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className="shrink-0">{trailing}</span>
|
||||
</Link>
|
||||
)
|
||||
|
||||
export const HomeSearchMunicipalityGroup = () => {
|
||||
const { results } = useHomeSearchResults()
|
||||
|
||||
if (results.status !== 'success') return null
|
||||
|
||||
const { municipalities, territories } = results.data
|
||||
if (!homeSearchMunicipalityGroupHasHits(results.data)) return null
|
||||
|
||||
return (
|
||||
<section aria-label="Municípios" className="flex flex-col gap-1">
|
||||
<h2 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Municípios
|
||||
</h2>
|
||||
<ul className="flex flex-col">
|
||||
{municipalities.map((hit) => (
|
||||
<li key={`municipality-${hit.slug}`}>
|
||||
<HomeSearchHitRow
|
||||
href={`/campanha/municipios/${hit.slug}`}
|
||||
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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
{territories.map((hit) => (
|
||||
<li key={`territory-${hit.region}`}>
|
||||
<HomeSearchHitRow
|
||||
href={buildTerritoryPageHref(hit.region)}
|
||||
primary={hit.region}
|
||||
showPriority={false}
|
||||
trailing={
|
||||
<span className="tabular-nums text-sm font-medium">
|
||||
{formatElectionNumber(hit.votes2022)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
114
src/components/campaign/dashboard/HomeSearchResultsContext.tsx
Normal file
114
src/components/campaign/dashboard/HomeSearchResultsContext.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
'use client'
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import type { HomeSearchSuccessResponse } from '@/lib/campaignHomeSearchHits'
|
||||
import { HOME_SEARCH_GENERIC_ERROR_MESSAGE } from '@/lib/campaignHomeSearchMessages'
|
||||
import { postCampaignJson } from '@/lib/campaignJsonRequest'
|
||||
|
||||
const HOME_SEARCH_ROUTE = '/campanha/home-search'
|
||||
|
||||
type HomeSearchResultsState =
|
||||
| { status: 'idle' }
|
||||
| { status: 'loading' }
|
||||
| { status: 'success'; data: HomeSearchSuccessResponse }
|
||||
| { status: 'error'; message: string }
|
||||
|
||||
export type HomeSearchResultsContextValue = {
|
||||
results: HomeSearchResultsState
|
||||
isFetching: boolean
|
||||
}
|
||||
|
||||
const HomeSearchResultsContext = createContext<HomeSearchResultsContextValue | null>(null)
|
||||
|
||||
export const HomeSearchResultsProvider = ({
|
||||
value,
|
||||
children,
|
||||
}: {
|
||||
value: HomeSearchResultsContextValue
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<HomeSearchResultsContext.Provider value={value}>{children}</HomeSearchResultsContext.Provider>
|
||||
)
|
||||
|
||||
export const useHomeSearchResults = (): HomeSearchResultsContextValue => {
|
||||
const ctx = useContext(HomeSearchResultsContext)
|
||||
if (!ctx) {
|
||||
throw new Error('useHomeSearchResults must be used within HomeSearchResultsProvider')
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
type HomeSearchErrorResponse = { status: 'error'; message: string }
|
||||
|
||||
export const useHomeSearchResultsState = (): HomeSearchResultsContextValue => {
|
||||
const { query, isDebouncing } = useHomeSearch()
|
||||
const [results, setResults] = useState<HomeSearchResultsState>({ status: 'idle' })
|
||||
const requestSeq = useRef(0)
|
||||
|
||||
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<
|
||||
HomeSearchSuccessResponse | HomeSearchErrorResponse
|
||||
>(HOME_SEARCH_ROUTE, { query: query.debounced }, controller.signal)
|
||||
|
||||
if (seq !== requestSeq.current) return
|
||||
|
||||
if (!ok) {
|
||||
const message =
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
'message' in payload &&
|
||||
payload.status === 'error'
|
||||
? payload.message
|
||||
: HOME_SEARCH_GENERIC_ERROR_MESSAGE
|
||||
setResults({ status: 'error', message })
|
||||
return
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object' && payload.status === 'success') {
|
||||
setResults({ status: 'success', data: payload })
|
||||
return
|
||||
}
|
||||
|
||||
setResults({ status: 'error', message: HOME_SEARCH_GENERIC_ERROR_MESSAGE })
|
||||
})().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 isFetching = isDebouncing || results.status === 'loading'
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
results,
|
||||
isFetching,
|
||||
}),
|
||||
[results, isFetching],
|
||||
)
|
||||
}
|
||||
32
src/components/campaign/dashboard/HomeSearchResultsShell.tsx
Normal file
32
src/components/campaign/dashboard/HomeSearchResultsShell.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { useHomeSearch } from '@/components/campaign/dashboard/HomeSearchContext'
|
||||
import { useHomeSearchResults } from '@/components/campaign/dashboard/HomeSearchResultsContext'
|
||||
import { homeSearchMunicipalityGroupHasHits } from '@/lib/campaignHomeSearchHits'
|
||||
|
||||
export const HomeSearchResultsShell = ({ children }: { children: ReactNode }) => {
|
||||
const { query } = useHomeSearch()
|
||||
const { results } = useHomeSearchResults()
|
||||
|
||||
const showEmpty =
|
||||
query.isActive &&
|
||||
results.status === 'success' &&
|
||||
!homeSearchMunicipalityGroupHasHits(results.data)
|
||||
|
||||
const showError = results.status === 'error'
|
||||
const errorMessage = results.status === 'error' ? results.message : null
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{showError && errorMessage ? (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{errorMessage}
|
||||
</p>
|
||||
) : null}
|
||||
{showEmpty ? <p className="text-sm text-muted-foreground">Nenhum resultado.</p> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import { MunicipalityListSignalControl } from '@/components/campaign/municipalit
|
|||
import { MunicipalityListTrendControl } from '@/components/campaign/municipality/MunicipalityListTrendControl'
|
||||
import { MunicipalityPriorityIndicator } from '@/components/campaign/municipality/MunicipalityPriorityIndicator'
|
||||
import { MunicipalitySortableHead } from '@/components/campaign/municipality/MunicipalitySortableHead'
|
||||
import { MunicipalityVotePositionReadout } from '@/components/campaign/municipality/MunicipalityVotePositionReadout'
|
||||
import { TerritoryLink } from '@/components/campaign/municipality/TerritoryLink'
|
||||
import { CampaignHoverTooltip } from '@/components/campaign/shared/CampaignHoverTooltip'
|
||||
import { CampaignTransitionAnchor } from '@/components/campaign/shared/CampaignListPending'
|
||||
|
|
@ -34,11 +35,6 @@ import {
|
|||
resolveVisibleColumns,
|
||||
type CampaignColumnVisibility,
|
||||
} from '@/lib/campaignColumnVisibility'
|
||||
import {
|
||||
formatElectionNumber,
|
||||
formatPlacementOrdinal,
|
||||
formatVoteSharePercent,
|
||||
} from '@/lib/electionFormat'
|
||||
import { formatEngagementLevelLabel } from '@/lib/engagementLevel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { CampaignFormActionState } from '@/utilities/campaignFormActionError'
|
||||
|
|
@ -106,32 +102,6 @@ export type MunicipalityListProps = {
|
|||
columnVisibility: CampaignColumnVisibility
|
||||
}
|
||||
|
||||
const VotePositionReadout = ({
|
||||
position,
|
||||
layout,
|
||||
}: {
|
||||
position: NonNullable<MunicipalityListViewModel['votePosition2022']>
|
||||
layout: 'table' | 'card'
|
||||
}) => {
|
||||
const share = formatVoteSharePercent(position.share)
|
||||
const rank = formatPlacementOrdinal(position.rank)
|
||||
const metaLine = `${formatElectionNumber(position.votes)} · ${rank}`
|
||||
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-0.5 tabular-nums',
|
||||
layout === 'table' ? 'items-end text-right' : 'text-sm',
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className={cn('font-medium', layout === 'card' && 'text-foreground')}>{share}</span>
|
||||
<span className="text-xs text-muted-foreground">{metaLine}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* E10 classe. Only the pill is visible — spelling the factors out under it
|
||||
* bloats the dense row on desktop and, on the mobile card, was the tallest
|
||||
|
|
@ -324,7 +294,7 @@ const municipalityListColumns = ({
|
|||
cellClassName: 'text-right',
|
||||
cell: (municipality) =>
|
||||
municipality.votePosition2022 ? (
|
||||
<VotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||||
<MunicipalityVotePositionReadout position={municipality.votePosition2022} layout="table" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
),
|
||||
|
|
@ -591,7 +561,9 @@ export const MunicipalityList = (props: MunicipalityListProps) => {
|
|||
</span>
|
||||
{zoneSuffix ? ` ${zoneSuffix}` : null}
|
||||
</p>
|
||||
{position ? <VotePositionReadout position={position} layout="card" /> : null}
|
||||
{position ? (
|
||||
<MunicipalityVotePositionReadout position={position} layout="card" />
|
||||
) : null}
|
||||
</div>
|
||||
{isPriority && isStaffView ? (
|
||||
<MunicipalityPriorityIndicator className="relative size-11" />
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import {
|
||||
formatElectionNumber,
|
||||
formatPlacementOrdinal,
|
||||
formatVoteSharePercent,
|
||||
} from '@/lib/electionFormat'
|
||||
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type MunicipalityVotePositionReadoutLayout = 'table' | 'card' | 'search'
|
||||
|
||||
export const MunicipalityVotePositionReadout = ({
|
||||
position,
|
||||
layout,
|
||||
}: {
|
||||
position: MunicipalityVoteRankEntry
|
||||
layout: MunicipalityVotePositionReadoutLayout
|
||||
}) => {
|
||||
const share = formatVoteSharePercent(position.share)
|
||||
const rank = formatPlacementOrdinal(position.rank)
|
||||
const metaLine =
|
||||
layout === 'search'
|
||||
? formatElectionNumber(position.votes)
|
||||
: `${formatElectionNumber(position.votes)} · ${rank}`
|
||||
const ariaLabel = `${share} da votação estadual, ${formatElectionNumber(position.votes)} votos, ${rank} de ${formatElectionNumber(position.totalUnits)}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-0.5 tabular-nums',
|
||||
layout === 'table' || layout === 'search' ? 'items-end text-right' : 'text-sm',
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span className={cn('font-medium', layout === 'card' && 'text-foreground')}>{share}</span>
|
||||
<span className="text-xs text-muted-foreground">{metaLine}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
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 const HOME_SEARCH_MIN_QUERY_LENGTH = 2
|
||||
|
||||
export type HomeSearchQuery = {
|
||||
raw: string
|
||||
|
|
|
|||
26
src/lib/campaignHomeSearchHits.ts
Normal file
26
src/lib/campaignHomeSearchHits.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { MunicipalityVoteRankEntry } from '@/lib/municipalityVoteRank'
|
||||
|
||||
export type HomeSearchMunicipalityHit = {
|
||||
kind: 'municipality'
|
||||
slug: string
|
||||
name: string
|
||||
region: string
|
||||
priority: 'alta' | 'normal' | null
|
||||
votePosition2022: MunicipalityVoteRankEntry | null
|
||||
}
|
||||
|
||||
export type HomeSearchTerritoryHit = {
|
||||
kind: 'territory'
|
||||
region: string
|
||||
votes2022: number
|
||||
}
|
||||
|
||||
export type HomeSearchSuccessResponse = {
|
||||
status: 'success'
|
||||
municipalities: HomeSearchMunicipalityHit[]
|
||||
territories: HomeSearchTerritoryHit[]
|
||||
}
|
||||
|
||||
export const homeSearchMunicipalityGroupHasHits = (
|
||||
data: Pick<HomeSearchSuccessResponse, 'municipalities' | 'territories'>,
|
||||
): boolean => data.municipalities.length > 0 || data.territories.length > 0
|
||||
5
src/lib/campaignHomeSearchMessages.ts
Normal file
5
src/lib/campaignHomeSearchMessages.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/** Exact string — `campaignJsonMutationRoute` `safeMessages` matches throws byte-for-byte. */
|
||||
export const HOME_SEARCH_STAFF_ONLY_MESSAGE =
|
||||
'Somente a equipe de campanha pode usar a busca do Início.'
|
||||
|
||||
export const HOME_SEARCH_GENERIC_ERROR_MESSAGE = 'Não foi possível buscar. Tente novamente.'
|
||||
31
src/lib/homeSearchMunicipalityMatch.ts
Normal file
31
src/lib/homeSearchMunicipalityMatch.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { normalizeSearchPhrase } from '@/lib/wordStartFilter'
|
||||
|
||||
/** Lower tier = stronger name match (word-start at label start beats mid-label). */
|
||||
export const homeSearchNameRelevanceTier = (
|
||||
normalizedLabel: string,
|
||||
normalizedQuery: string,
|
||||
): number => {
|
||||
if (!normalizedQuery) return 0
|
||||
if (normalizedLabel.startsWith(normalizedQuery)) return 0
|
||||
if (normalizedLabel.includes(` ${normalizedQuery}`)) return 1
|
||||
return 2
|
||||
}
|
||||
|
||||
export type HomeSearchNameSortable = {
|
||||
normalizedName: string
|
||||
votes2022: number
|
||||
}
|
||||
|
||||
export const compareHomeSearchNameRelevance = (
|
||||
left: HomeSearchNameSortable,
|
||||
right: HomeSearchNameSortable,
|
||||
normalizedQuery: string,
|
||||
): number => {
|
||||
const tierDelta =
|
||||
homeSearchNameRelevanceTier(left.normalizedName, normalizedQuery) -
|
||||
homeSearchNameRelevanceTier(right.normalizedName, normalizedQuery)
|
||||
if (tierDelta !== 0) return tierDelta
|
||||
return right.votes2022 - left.votes2022
|
||||
}
|
||||
|
||||
export const normalizeHomeSearchName = (name: string): string => normalizeSearchPhrase(name)
|
||||
13
src/lib/schemas/homeSearch.ts
Normal file
13
src/lib/schemas/homeSearch.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { z } from 'zod'
|
||||
|
||||
import { HOME_SEARCH_MIN_QUERY_LENGTH } from '@/lib/campaignHomeSearchContract'
|
||||
|
||||
export const HOME_SEARCH_QUERY_MAX_LENGTH = 80
|
||||
|
||||
export const homeSearchBodySchema = z.object({
|
||||
query: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(HOME_SEARCH_MIN_QUERY_LENGTH, 'Digite pelo menos 2 caracteres.')
|
||||
.max(HOME_SEARCH_QUERY_MAX_LENGTH),
|
||||
})
|
||||
119
src/utilities/homeSearch/searchHomeMunicipalities.ts
Normal file
119
src/utilities/homeSearch/searchHomeMunicipalities.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'server-only'
|
||||
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
import { homeSearchQueryIsActive, normalizeHomeSearchRaw } from '@/lib/campaignHomeSearchContract'
|
||||
import type {
|
||||
HomeSearchMunicipalityHit,
|
||||
HomeSearchSuccessResponse,
|
||||
HomeSearchTerritoryHit,
|
||||
} from '@/lib/campaignHomeSearchHits'
|
||||
import { HOME_SEARCH_STAFF_ONLY_MESSAGE } from '@/lib/campaignHomeSearchMessages'
|
||||
import { isStaffCampaignRole } from '@/lib/campaignRoles'
|
||||
import {
|
||||
compareHomeSearchNameRelevance,
|
||||
normalizeHomeSearchName,
|
||||
} from '@/lib/homeSearchMunicipalityMatch'
|
||||
import { DEFAULT_VOTE_RANK_YEAR, getMunicipalityVoteRank } 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'
|
||||
|
||||
const emptyResponse = (): HomeSearchSuccessResponse => ({
|
||||
status: 'success',
|
||||
municipalities: [],
|
||||
territories: [],
|
||||
})
|
||||
|
||||
export const searchHomeMunicipalities = async (
|
||||
payload: Payload,
|
||||
user: CampaignUser,
|
||||
rawQuery: string,
|
||||
): Promise<HomeSearchSuccessResponse> => {
|
||||
if (!isStaffCampaignRole(user.role)) {
|
||||
throw new Error(HOME_SEARCH_STAFF_ONLY_MESSAGE)
|
||||
}
|
||||
|
||||
const query = normalizeHomeSearchRaw(rawQuery)
|
||||
if (!homeSearchQueryIsActive(query)) {
|
||||
return emptyResponse()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const votePosition2022 = getMunicipalityVoteRank(doc.slug, DEFAULT_VOTE_RANK_YEAR)
|
||||
return {
|
||||
normalizedName,
|
||||
hit: {
|
||||
kind: 'municipality' as const,
|
||||
slug: doc.slug,
|
||||
name: doc.name,
|
||||
region: doc.region,
|
||||
priority: doc.priority ?? null,
|
||||
votePosition2022,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
.sort((left, right) =>
|
||||
compareHomeSearchNameRelevance(
|
||||
{
|
||||
normalizedName: left.normalizedName,
|
||||
votes2022: left.hit.votePosition2022?.votes ?? 0,
|
||||
},
|
||||
{
|
||||
normalizedName: right.normalizedName,
|
||||
votes2022: right.hit.votePosition2022?.votes ?? 0,
|
||||
},
|
||||
normalizedQuery,
|
||||
),
|
||||
)
|
||||
.map((row) => row.hit)
|
||||
|
||||
const territoryRows = await loadTerritoryOverview(payload, user)
|
||||
const territoryHits: HomeSearchTerritoryHit[] = territoryRows
|
||||
.map((row) => {
|
||||
const normalizedName = normalizeHomeSearchName(row.region)
|
||||
if (!matchesNormalizedAtWordStart(normalizedName, normalizedQuery)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
normalizedName,
|
||||
hit: {
|
||||
kind: 'territory' as const,
|
||||
region: row.region,
|
||||
votes2022: row.votesByYear[DEFAULT_VOTE_RANK_YEAR] ?? 0,
|
||||
},
|
||||
}
|
||||
})
|
||||
.filter((row): row is NonNullable<typeof row> => row !== null)
|
||||
.sort((left, right) =>
|
||||
compareHomeSearchNameRelevance(
|
||||
{
|
||||
normalizedName: left.normalizedName,
|
||||
votes2022: left.hit.votes2022,
|
||||
},
|
||||
{
|
||||
normalizedName: right.normalizedName,
|
||||
votes2022: right.hit.votes2022,
|
||||
},
|
||||
normalizedQuery,
|
||||
),
|
||||
)
|
||||
.map((row) => row.hit)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
municipalities: municipalityHits,
|
||||
territories: territoryHits,
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,27 @@ test.describe('Início — busca global (B47)', () => {
|
|||
await page.getByLabel('Buscar na campanha').fill('')
|
||||
await expect(heading).toBeVisible({ timeout: 5000 })
|
||||
})
|
||||
|
||||
test('staff search shows municipality hits and opens detail (B48)', 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 search = page.getByLabel('Buscar na campanha')
|
||||
await search.fill('Cairu')
|
||||
|
||||
const group = page.getByRole('region', { name: 'Resultados da busca' })
|
||||
await expect(group.getByRole('heading', { name: 'Municípios' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
await group.getByRole('link', { name: /Cairu/i }).click()
|
||||
await page.waitForURL(/\/campanha\/municipios\/cairu/)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Início — catálogo de ações (B45)', () => {
|
||||
|
|
|
|||
72
tests/int/homeSearchMunicipalities.int.spec.ts
Normal file
72
tests/int/homeSearchMunicipalities.int.spec.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// @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 { searchHomeMunicipalities } from '@/utilities/homeSearch/searchHomeMunicipalities'
|
||||
|
||||
import { installCampaignFixtures } from '../helpers/campaignFixtures'
|
||||
|
||||
let payload: Payload
|
||||
const campaignFixtures = installCampaignFixtures({
|
||||
getPayload: () => payload,
|
||||
setPayload: (nextPayload) => {
|
||||
payload = nextPayload
|
||||
},
|
||||
})
|
||||
|
||||
describe('searchHomeMunicipalities (B48)', () => {
|
||||
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 searchHomeMunicipalities(payload, coordinator, 'a')
|
||||
expect(result.municipalities).toEqual([])
|
||||
expect(result.territories).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 searchHomeMunicipalities(payload, coordinator, 'Cai')
|
||||
expect(result.municipalities.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 searchHomeMunicipalities(payload, advisor, 'Feira')
|
||||
expect(advisorResult.municipalities.map((hit) => hit.slug)).toEqual([])
|
||||
|
||||
const coordinatorResult = await searchHomeMunicipalities(payload, coordinator, 'Feira')
|
||||
expect(coordinatorResult.municipalities.some((hit) => hit.slug === other.slug)).toBe(true)
|
||||
|
||||
const advisorCairu = await searchHomeMunicipalities(payload, advisor, 'Cairu')
|
||||
expect(advisorCairu.municipalities.map((hit) => hit.slug)).toEqual(['cairu'])
|
||||
})
|
||||
|
||||
it('rejects leaders from home search', async () => {
|
||||
const fixtures = campaignFixtures()
|
||||
const leader = await fixtures.createCampaignUser('leader')
|
||||
|
||||
await expect(searchHomeMunicipalities(payload, leader, 'Cairu')).rejects.toThrow(
|
||||
/equipe de campanha/i,
|
||||
)
|
||||
})
|
||||
})
|
||||
24
tests/unit/homeSearchMunicipalityMatch.unit.spec.ts
Normal file
24
tests/unit/homeSearchMunicipalityMatch.unit.spec.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
compareHomeSearchNameRelevance,
|
||||
homeSearchNameRelevanceTier,
|
||||
} from '@/lib/homeSearchMunicipalityMatch'
|
||||
|
||||
describe('homeSearchMunicipalityMatch', () => {
|
||||
it('ranks prefix match before word-start later in the label', () => {
|
||||
expect(homeSearchNameRelevanceTier('cairu', 'cai')).toBe(0)
|
||||
expect(homeSearchNameRelevanceTier('porto seguro', 'seg')).toBe(1)
|
||||
})
|
||||
|
||||
it('sorts by relevance tier then votes 2022 desc', () => {
|
||||
const normalizedQuery = 'cai'
|
||||
const hits = [
|
||||
{ normalizedName: 'porto seguro', votes2022: 9000 },
|
||||
{ normalizedName: 'cairu', votes2022: 100 },
|
||||
{ normalizedName: 'caicara', votes2022: 5000 },
|
||||
]
|
||||
const sorted = [...hits].sort((a, b) => compareHomeSearchNameRelevance(a, b, normalizedQuery))
|
||||
expect(sorted.map((row) => row.normalizedName)).toEqual(['caicara', 'cairu', 'porto seguro'])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue