Merge pull request #100 from fsolla/cursor/pool-i94-64346b65-f089

B94 — Empty state do wizard: município mais próximo (geo)
This commit is contained in:
Francisco Solla 2026-08-01 05:32:21 -03:00 committed by GitHub
commit 3f7172f119
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 163 additions and 23 deletions

View file

@ -105,6 +105,9 @@ export const E2E_AFFECTED_MANIFEST = [
'src/components/campaign/votePledge',
'src/components/campaign/auth',
`${CAMPAIGN_APP}/acoes`,
'src/components/campaign/shared/WizardMunicipality',
'src/components/campaign/shared/useWizardNearestMunicipality',
'src/lib/wizardMunicipalitySuggestMerge',
`${CAMPAIGN_APP}/apoiadores`,
`${CAMPAIGN_APP}/assessores`,
`${CAMPAIGN_APP}/contatos`,

View file

@ -91,7 +91,22 @@ export default async function CampaignActionWizardPage({
resolvedSearchParams[WIZARD_MUNICIPIO_QUERY_KEY],
)
if (!municipalitySlug) {
return <WizardMunicipalitySearchStep actionSlug={slug} previousHref={CAMPAIGN_HOME} />
const user = await requireCampaignPageActor()
const payload = await getPayload({ config: await config })
const { municipalities } = await loadMunicipalityScope(payload, user, {})
const accessibleMunicipalities = municipalities.map(({ slug, name, ibgeCode }) => ({
slug,
name,
ibgeCode,
}))
return (
<WizardMunicipalitySearchStep
actionSlug={slug}
previousHref={CAMPAIGN_HOME}
accessibleMunicipalities={accessibleMunicipalities}
/>
)
}
const needsPoliticalTrend = slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend']

View file

@ -7,6 +7,7 @@ import { HomeSearchMunicipalityVoteTrailing } from '@/components/campaign/dashbo
import { useHomeSearchQuery } from '@/components/campaign/dashboard/useHomeSearchQuery'
import { CampaignSearchInput } from '@/components/campaign/shared/CampaignSearchInput'
import { CampaignWizardShell } from '@/components/campaign/shared/CampaignWizardShell'
import { useWizardNearestMunicipality } from '@/components/campaign/shared/useWizardNearestMunicipality'
import { wizardActionHref } from '@/lib/campaignActionRoutes'
import type {
HomeSearchMunicipalityHit,
@ -23,6 +24,7 @@ import {
WIZARD_MUNICIPALITY_STEP_TITLE,
wizardFlowTitleForSlug,
} from '@/lib/campaignWizardCopy'
import type { AccessibleMunicipality } from '@/lib/municipalityProximity'
import { HOME_SEARCH_QUERY_MAX_LENGTH } from '@/lib/schemas/homeSearch'
import {
listWizardContinuitySlugs,
@ -49,6 +51,7 @@ type WizardSearchResultsState =
type WizardMunicipalitySearchStepProps = {
actionSlug: string
previousHref: string
accessibleMunicipalities: AccessibleMunicipality[]
}
const buildSuggestSuccessState = (
@ -67,6 +70,7 @@ const buildSuggestSuccessState = (
export const WizardMunicipalitySearchStep = ({
actionSlug,
previousHref,
accessibleMunicipalities,
}: WizardMunicipalitySearchStepProps) => {
const { query, setRaw, isDebouncing } = useHomeSearchQuery()
const [results, setResults] = useState<WizardSearchResultsState>({ status: 'idle' })
@ -76,6 +80,7 @@ export const WizardMunicipalitySearchStep = ({
const suggestMode = !query.isActive
const searchMode = query.isActive
const resultsBusy = isDebouncing || results.status === 'loading'
const nearestMunicipality = useWizardNearestMunicipality(accessibleMunicipalities, suggestMode)
useEffect(() => {
if (!suggestMode || results.status !== 'success' || results.mode !== 'suggest') {
@ -96,12 +101,6 @@ export const WizardMunicipalitySearchStep = ({
}, [results, suggestMode])
useEffect(() => {
if (!suggestMode && !searchMode) {
requestSeq.current += 1
setResults({ status: 'idle' })
return
}
const seq = ++requestSeq.current
const controller = new AbortController()
setResults({ status: 'loading' })
@ -156,16 +155,13 @@ export const WizardMunicipalitySearchStep = ({
return results.municipalities.map((hit) => ({ hit, continuityReason: undefined }))
}
if (!continuityReady) {
return results.suggest.municipalities.map((hit) => ({ hit, continuityReason: undefined }))
}
return mergeWizardMunicipalitySuggestions({
continuity: continuitySlugs,
continuity: continuityReady ? continuitySlugs : [],
geo: nearestMunicipality,
serverHits: results.suggest.municipalities,
hitBySlug: results.suggest.hitBySlug,
})
}, [continuityReady, continuitySlugs, results])
}, [continuityReady, continuitySlugs, nearestMunicipality, results])
const showEmpty =
query.isActive &&

View file

@ -0,0 +1,74 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import {
loadMunicipalityGeometryModule,
loadMunicipalityZoneGeometryModule,
} from '@/lib/bahiaGeometries'
import { resolveNearbyMunicipality, type AccessibleMunicipality } from '@/lib/municipalityProximity'
import type { WizardGeoMunicipalitySuggestion } from '@/lib/wizardMunicipalitySuggestMerge'
import {
readGeolocationPermissionState,
requestCurrentPosition,
} from '@/utilities/campaignGeolocation'
/**
* B94 — resolves the nearest in-scope município for the wizard idle state.
* Only runs when geolocation permission is already `granted` (prompt lives on the Quadro).
* Fail-soft: every other outcome returns `null` with no error UI.
*/
export const useWizardNearestMunicipality = (
accessible: readonly AccessibleMunicipality[],
enabled: boolean,
): WizardGeoMunicipalitySuggestion | null => {
const [geo, setGeo] = useState<WizardGeoMunicipalitySuggestion | null>(null)
const autoStartedRef = useRef(false)
useEffect(() => {
if (!enabled || autoStartedRef.current || accessible.length === 0) return
autoStartedRef.current = true
let cancelled = false
const resolve = async () => {
const permission = await readGeolocationPermissionState()
if (permission !== 'granted') return
const loaded = await Promise.all([
requestCurrentPosition(),
loadMunicipalityGeometryModule(),
]).catch(() => null)
if (cancelled || !loaded) return
const [result, geometry] = loaded
if (!result.ok) return
const zoneModule = await loadMunicipalityZoneGeometryModule().catch(() => null)
const resolution = resolveNearbyMunicipality({
point: result.fix,
geometry,
zoneGeometry: zoneModule ?? undefined,
accessible,
})
if (cancelled || resolution.kind !== 'inScope') return
setGeo({
slug: resolution.municipality.slug,
name: resolution.municipality.name,
distanceKm: resolution.match === 'nearestZone' ? resolution.distanceKm : undefined,
})
}
void resolve()
return () => {
cancelled = true
}
}, [accessible, enabled])
return geo
}

View file

@ -1,5 +1,8 @@
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
import { formatDistanceKm } from '@/lib/municipalityProximity'
export const WIZARD_GEO_NEARBY_REASON = 'Perto de você' as const
export const WIZARD_CONTINUITY_LAST_ACTED_LABEL = 'Última ação' as const
export const WIZARD_CONTINUITY_VISITED_LABEL = 'Visitado' as const
@ -18,6 +21,12 @@ export type WizardContinuityVisitInput = {
kind: 'municipality' | 'municipalityList'
}
export type WizardGeoMunicipalitySuggestion = {
slug: string
name: string
distanceKm?: number
}
export type WizardMunicipalityMergedRow = {
hit: HomeSearchMunicipalityHit
continuityReason?: string
@ -31,6 +40,19 @@ export const municipalitySlugFromRecentVisitHref = (href: string): string | null
return slug ? slug : null
}
/** Secondary line for a geo-resolved wizard hit — region first when known. */
export const formatWizardGeoSecondary = (
region: string | undefined,
distanceKm?: number,
): string => {
const reason =
distanceKm !== undefined
? `${WIZARD_GEO_NEARBY_REASON} (~${formatDistanceKm(distanceKm)})`
: WIZARD_GEO_NEARBY_REASON
return region ? `${region} · ${reason}` : reason
}
const continuityReasonForSource = (source: WizardContinuitySource): string => {
switch (source) {
case 'last-acted':
@ -81,12 +103,12 @@ export const listWizardContinuitySlugs = (input: {
}
/**
* B93 — merge client continuity (last-acted + recent visits) ahead of B92 server
* suggestions. Dedup order: geo (B94) → last-acted → visited → forgotten.
* B93 + B94 — merge geo (B94), continuity (B93) and server suggestions (B92).
* Dedup order: geo → last-acted → visited → forgotten.
*/
export const mergeWizardMunicipalitySuggestions = (input: {
continuity: WizardContinuitySlug[]
geoSlug?: string | null
geo?: WizardGeoMunicipalitySuggestion | null
serverHits: HomeSearchMunicipalityHit[]
hitBySlug: ReadonlyMap<string, HomeSearchMunicipalityHit>
limit?: number
@ -95,17 +117,23 @@ export const mergeWizardMunicipalitySuggestions = (input: {
const merged: WizardMunicipalityMergedRow[] = []
const seen = new Set<string>()
const pushHit = (slug: string, continuityReason?: string) => {
const pushHit = (slug: string, continuityReason?: string, regionOverride?: string) => {
if (seen.has(slug)) return
const hit = input.hitBySlug.get(slug)
if (!hit) return
seen.add(slug)
merged.push({ hit, continuityReason })
merged.push({
hit: regionOverride ? { ...hit, region: regionOverride } : hit,
continuityReason,
})
}
const geoSlug = input.geoSlug?.trim()
const geoSlug = input.geo?.slug.trim()
if (geoSlug) {
pushHit(geoSlug)
const baseHit = input.hitBySlug.get(geoSlug)
if (baseHit) {
pushHit(geoSlug, undefined, formatWizardGeoSecondary(baseHit.region, input.geo?.distanceKm))
}
}
for (const item of input.continuity) {

View file

@ -3,11 +3,13 @@ import { describe, expect, it } from 'vitest'
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
import {
formatWizardGeoSecondary,
listWizardContinuitySlugs,
mergeWizardMunicipalitySuggestions,
municipalitySlugFromRecentVisitHref,
WIZARD_CONTINUITY_LAST_ACTED_LABEL,
WIZARD_CONTINUITY_VISITED_LABEL,
WIZARD_GEO_NEARBY_REASON,
type WizardContinuityVisitInput,
} from '@/lib/wizardMunicipalitySuggestMerge'
@ -29,6 +31,24 @@ const visit = (
...overrides,
})
describe('formatWizardGeoSecondary', () => {
it('formats reason without distance', () => {
expect(formatWizardGeoSecondary(undefined)).toBe(WIZARD_GEO_NEARBY_REASON)
expect(formatWizardGeoSecondary('Costa do Dendê')).toBe(
`Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON}`,
)
})
it('formats reason with distance', () => {
expect(formatWizardGeoSecondary('Costa do Dendê', 1.2)).toBe(
`Costa do Dendê · ${WIZARD_GEO_NEARBY_REASON} (~1,2 km)`,
)
expect(formatWizardGeoSecondary(undefined, 0.4)).toBe(
`${WIZARD_GEO_NEARBY_REASON} (~menos de 1 km)`,
)
})
})
describe('municipalitySlugFromRecentVisitHref', () => {
it('extracts municipality slug from detail href', () => {
expect(municipalitySlugFromRecentVisitHref('/campanha/municipios/itabuna')).toBe('itabuna')
@ -92,27 +112,31 @@ describe('mergeWizardMunicipalitySuggestions', () => {
hit({ slug: 'geo', name: 'Geo' }),
hit({ slug: 'acted', name: 'Acted' }),
hit({ slug: 'visited', name: 'Visited' }),
hit({ slug: 'forgot-a', name: 'Forgot A' }),
hit({ slug: 'forgot-a', name: 'Forgot A', region: 'Costa do Dendê' }),
hit({ slug: 'forgot-b', name: 'Forgot B' }),
].map((row) => [row.slug, row]),
)
it('dedupes in geo > continuity > server order with reason labels', () => {
const merged = mergeWizardMunicipalitySuggestions({
geoSlug: 'forgot-a',
geo: { slug: 'forgot-a', name: 'Forgot A', distanceKm: 2.5 },
continuity: [
{ source: 'last-acted', slug: 'acted' },
{ source: 'visited', slug: 'visited' },
{ source: 'visited', slug: 'forgot-a' },
],
serverHits: [
hit({ slug: 'forgot-a', name: 'Forgot A' }),
hit({ slug: 'forgot-a', name: 'Forgot A', region: 'Costa do Dendê' }),
hit({ slug: 'forgot-b', name: 'Forgot B' }),
],
hitBySlug,
})
expect(merged.map((row) => row.hit.slug)).toEqual(['forgot-a', 'acted', 'visited', 'forgot-b'])
expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain(
WIZARD_GEO_NEARBY_REASON,
)
expect(merged.find((row) => row.hit.slug === 'forgot-a')?.hit.region).toContain('~2,5 km')
expect(merged.find((row) => row.hit.slug === 'acted')?.continuityReason).toBe(
WIZARD_CONTINUITY_LAST_ACTED_LABEL,
)