B94 — wizard empty state: município mais próximo (geo)
No idle do passo de busca de município, resolve client-side o município mais próximo no escopo do ator quando a permissão de geo já está granted (reuso B14: campaignGeolocation + municipalityProximity + malha lazy). - Prop RSC accessibleMunicipalities na rota acoes/[slug] - Hook useWizardNearestMunicipality (fail-soft, só inScope) - Merge geo no topo da lista B92 via wizardMunicipalitySuggestMerge - Unit tests do merge/format Closes #94 Co-authored-by: Francisco Solla <fsolla@users.noreply.github.com>
This commit is contained in:
parent
eb80f8c32c
commit
20c69bd0de
5 changed files with 207 additions and 20 deletions
|
|
@ -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']
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<HomeSearchMunicipalityHit> & Pick<HomeSearchMunicipalityHit, 'slug' | 'name'>,
|
||||
): 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> = {},
|
||||
): 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))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue