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)) }) })