fix(wizard): cache geo singleton, feedback carga, debounce draft

geo_paraguay_full.json se cargaba dos veces sin cache compartido.
Módulo lib/geo-cache.ts singleton: un fetch para toda la sesión.
Feedback visual mientras carga (disabled + placeholder) y si falla
(mensaje + texto libre). debounce 500ms en saveDraft para no escribir
en localStorage en cada keystroke.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
markosbenitez
2026-06-28 22:04:12 -03:00
parent 5472043a33
commit 704d2407e8
4 changed files with 42 additions and 13 deletions

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react' import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { getGeoData, type GeoData } from '@/lib/geo-cache'
type GeoData = Record<string, Record<string, string[]>>
interface BarrioAutocompleteFieldProps { interface BarrioAutocompleteFieldProps {
name: string name: string
@@ -25,16 +24,16 @@ export function BarrioAutocompleteField({
error, error,
}: BarrioAutocompleteFieldProps) { }: BarrioAutocompleteFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null) const [geoData, setGeoData] = useState<GeoData | null>(null)
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1) const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({}) const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => { useEffect(() => {
fetch('/data/geo_paraguay_full.json') getGeoData()
.then((r) => r.json()) .then((data) => { setGeoData(data); setGeoStatus('ready') })
.then(setGeoData) .catch(() => setGeoStatus('error'))
.catch(() => {})
}, []) }, [])
// Close dropdown on scroll so the fixed-position list doesn't drift from the input // Close dropdown on scroll so the fixed-position list doesn't drift from the input
@@ -126,6 +125,12 @@ export function BarrioAutocompleteField({
autoComplete="off" autoComplete="off"
className="text-short-input" className="text-short-input"
ref={inputRef} ref={inputRef}
disabled={geoStatus === 'loading'}
placeholder={
geoStatus === 'loading' ? 'Cargando opciones...' :
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
undefined
}
onChange={(e) => handleInput(e.target.value)} onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }} onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}

View File

@@ -1,7 +1,6 @@
'use client' 'use client'
import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react' import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import { getGeoData, type GeoData } from '@/lib/geo-cache'
type GeoData = Record<string, Record<string, string[]>>
interface CiudadSelectFieldProps { interface CiudadSelectFieldProps {
name: string name: string
@@ -23,6 +22,7 @@ export function CiudadSelectField({
error, error,
}: CiudadSelectFieldProps) { }: CiudadSelectFieldProps) {
const [geoData, setGeoData] = useState<GeoData | null>(null) const [geoData, setGeoData] = useState<GeoData | null>(null)
const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1) const [activeIdx, setActiveIdx] = useState(-1)
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({}) const [dropdownStyle, setDropdownStyle] = useState<CSSProperties>({})
@@ -35,10 +35,9 @@ export function CiudadSelectField({
const prevDeptoRef = useRef(departamento) const prevDeptoRef = useRef(departamento)
useEffect(() => { useEffect(() => {
fetch('/data/geo_paraguay_full.json') getGeoData()
.then((r) => r.json()) .then((data) => { setGeoData(data); setGeoStatus('ready') })
.then(setGeoData) .catch(() => setGeoStatus('error'))
.catch(() => {})
}, []) }, [])
// Clear city when departamento changes so no orphan value remains // Clear city when departamento changes so no orphan value remains
@@ -130,6 +129,12 @@ export function CiudadSelectField({
autoComplete="off" autoComplete="off"
className="text-short-input" className="text-short-input"
ref={inputRef} ref={inputRef}
disabled={geoStatus === 'loading'}
placeholder={
geoStatus === 'loading' ? 'Cargando opciones...' :
geoStatus === 'error' ? 'No se pudieron cargar las opciones. Podés escribir el nombre manualmente.' :
undefined
}
onChange={(e) => handleInput(e.target.value)} onChange={(e) => handleInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }} onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }}

View File

@@ -70,7 +70,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
useEffect(() => { useEffect(() => {
if (!hydrated) return if (!hydrated) return
saveDraft(survey.id, state.answers) const timer = setTimeout(() => saveDraft(survey.id, state.answers), 500)
return () => clearTimeout(timer)
}, [state.answers, hydrated, survey.id]) }, [state.answers, hydrated, survey.id])
const screens = resolveScreens(schema, state.answers) const screens = resolveScreens(schema, state.answers)
@@ -192,10 +193,12 @@ export function WizardForm({ survey, schema }: WizardFormProps) {
setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' })) setErrors((e) => ({ ...e, [screen.question!.code]: 'Este campo es obligatorio.' }))
return return
} }
saveDraft(survey.id, state.answers)
setState((s) => next(s, schema)) setState((s) => next(s, schema))
} }
function handleBack() { function handleBack() {
saveDraft(survey.id, state.answers)
setState((s) => back(s, schema)) setState((s) => back(s, schema))
} }

16
lib/geo-cache.ts Normal file
View File

@@ -0,0 +1,16 @@
// departamento → ciudad → barrios[]
export type GeoData = Record<string, Record<string, string[]>>
let geoData: GeoData | null = null
let geoPromise: Promise<GeoData> | null = null
export async function getGeoData(): Promise<GeoData> {
if (geoData) return geoData
if (!geoPromise) {
geoPromise = fetch('/data/geo_paraguay_full.json')
.then((r) => r.json())
.then((data: GeoData) => { geoData = data; return data })
.catch((err) => { geoPromise = null; throw err })
}
return geoPromise
}