diff --git a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
index 4dd96522..2a6b42dd 100644
--- a/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
+++ b/src/app/(campaign)/campanha/(app)/acoes/[slug]/page.tsx
@@ -91,7 +91,22 @@ export default async function CampaignActionWizardPage({
resolvedSearchParams[WIZARD_MUNICIPIO_QUERY_KEY],
)
if (!municipalitySlug) {
- return
+ 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 (
+
+ )
}
const needsPoliticalTrend = slug === CAMPAIGN_WIZARD_ACTION_SLUGS['change-trend']
diff --git a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
index 38751788..c2eb9794 100644
--- a/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
+++ b/src/components/campaign/shared/WizardMunicipalitySearchStep.tsx
@@ -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({ 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 &&
diff --git a/src/components/campaign/shared/useWizardNearestMunicipality.ts b/src/components/campaign/shared/useWizardNearestMunicipality.ts
new file mode 100644
index 00000000..073929f2
--- /dev/null
+++ b/src/components/campaign/shared/useWizardNearestMunicipality.ts
@@ -0,0 +1,77 @@
+'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(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
+}
diff --git a/src/lib/wizardMunicipalitySuggestMerge.ts b/src/lib/wizardMunicipalitySuggestMerge.ts
index f491545b..e698500f 100644
--- a/src/lib/wizardMunicipalitySuggestMerge.ts
+++ b/src/lib/wizardMunicipalitySuggestMerge.ts
@@ -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
limit?: number
@@ -95,17 +117,23 @@ export const mergeWizardMunicipalitySuggestions = (input: {
const merged: WizardMunicipalityMergedRow[] = []
const seen = new Set()
- 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) {
diff --git a/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts
index 55136068..db2fc9fc 100644
--- a/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts
+++ b/tests/unit/wizardMunicipalitySuggestMerge.unit.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { HomeSearchMunicipalityHit } from '@/lib/campaignHomeSearchHits'
+<<<<<<< HEAD
import { HOME_SEARCH_SUGGEST_LIMIT } from '@/lib/homeSearchSuggest'
import {
listWizardContinuitySlugs,
@@ -16,11 +17,25 @@ const hit = (
): HomeSearchMunicipalityHit => ({
kind: 'municipality',
region: 'Região',
+=======
+import {
+ formatWizardGeoSecondary,
+ mergeWizardMunicipalitySuggestions,
+ WIZARD_GEO_NEARBY_REASON,
+} from '@/lib/wizardMunicipalitySuggestMerge'
+
+const serverHit = (
+ partial: Partial & Pick,
+): HomeSearchMunicipalityHit => ({
+ kind: 'municipality',
+ region: 'Costa do Dendê',
+>>>>>>> 076bd2f (B94 — wizard empty state: município mais próximo (geo))
priority: null,
votePosition2022: null,
...partial,
})
+<<<<<<< HEAD
const visit = (
overrides: Partial = {},
): WizardContinuityVisitInput => ({
@@ -83,10 +98,28 @@ describe('listWizardContinuitySlugs', () => {
})
expect(continuity.map((item) => item.slug)).toEqual(['a', 'b', 'c'])
+=======
+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)`,
+ )
+>>>>>>> 076bd2f (B94 — wizard empty state: município mais próximo (geo))
})
})
describe('mergeWizardMunicipalitySuggestions', () => {
+<<<<<<< HEAD
const hitBySlug = new Map(
[
hit({ slug: 'geo', name: 'Geo' }),
@@ -135,5 +168,43 @@ describe('mergeWizardMunicipalitySuggestions', () => {
})
expect(merged).toHaveLength(HOME_SEARCH_SUGGEST_LIMIT)
+=======
+ it('returns server hits unchanged when geo is null', () => {
+ const hits = [serverHit({ slug: 'cairu', name: 'Cairu' })]
+
+ expect(mergeWizardMunicipalitySuggestions(hits, null)).toEqual(hits)
+ })
+
+ it('prepends geo hit and dedupes by slug', () => {
+ const hits = [
+ serverHit({ slug: 'cairu', name: 'Cairu', priority: 'alta' }),
+ serverHit({ slug: 'valenca', name: 'Valença' }),
+ ]
+
+ const merged = mergeWizardMunicipalitySuggestions(hits, {
+ slug: 'valenca',
+ name: 'Valença',
+ distanceKm: 2.5,
+ })
+
+ expect(merged).toHaveLength(2)
+ expect(merged[0]?.slug).toBe('valenca')
+ expect(merged[0]?.region).toContain(WIZARD_GEO_NEARBY_REASON)
+ expect(merged[0]?.region).toContain('~2,5 km')
+ expect(merged[1]?.slug).toBe('cairu')
+ })
+
+ it('preserves server metadata when geo slug was not in the suggest list', () => {
+ const hits = [serverHit({ slug: 'cairu', name: 'Cairu' })]
+
+ const merged = mergeWizardMunicipalitySuggestions(hits, {
+ slug: 'itabuna',
+ name: 'Itabuna',
+ })
+
+ expect(merged).toHaveLength(2)
+ expect(merged[0]?.slug).toBe('itabuna')
+ expect(merged[0]?.region).toBe(WIZARD_GEO_NEARBY_REASON)
+>>>>>>> 076bd2f (B94 — wizard empty state: município mais próximo (geo))
})
})