From 704d2407e87d787594280b5be38223b8a6594aaa Mon Sep 17 00:00:00 2001 From: markosbenitez Date: Sun, 28 Jun 2026 22:04:12 -0300 Subject: [PATCH] fix(wizard): cache geo singleton, feedback carga, debounce draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../widgets/barrio-autocomplete-field.tsx | 17 +++++++++++------ components/widgets/ciudad-select-field.tsx | 17 +++++++++++------ components/wizard-form.tsx | 5 ++++- lib/geo-cache.ts | 16 ++++++++++++++++ 4 files changed, 42 insertions(+), 13 deletions(-) create mode 100644 lib/geo-cache.ts diff --git a/components/widgets/barrio-autocomplete-field.tsx b/components/widgets/barrio-autocomplete-field.tsx index caf3dd8..c94e950 100644 --- a/components/widgets/barrio-autocomplete-field.tsx +++ b/components/widgets/barrio-autocomplete-field.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react' - -type GeoData = Record> +import { getGeoData, type GeoData } from '@/lib/geo-cache' interface BarrioAutocompleteFieldProps { name: string @@ -25,16 +24,16 @@ export function BarrioAutocompleteField({ error, }: BarrioAutocompleteFieldProps) { const [geoData, setGeoData] = useState(null) + const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [open, setOpen] = useState(false) const [activeIdx, setActiveIdx] = useState(-1) const [dropdownStyle, setDropdownStyle] = useState({}) const inputRef = useRef(null) useEffect(() => { - fetch('/data/geo_paraguay_full.json') - .then((r) => r.json()) - .then(setGeoData) - .catch(() => {}) + getGeoData() + .then((data) => { setGeoData(data); setGeoStatus('ready') }) + .catch(() => setGeoStatus('error')) }, []) // Close dropdown on scroll so the fixed-position list doesn't drift from the input @@ -126,6 +125,12 @@ export function BarrioAutocompleteField({ autoComplete="off" className="text-short-input" 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)} onKeyDown={handleKeyDown} onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }} diff --git a/components/widgets/ciudad-select-field.tsx b/components/widgets/ciudad-select-field.tsx index ec2b7ec..f4b5eed 100644 --- a/components/widgets/ciudad-select-field.tsx +++ b/components/widgets/ciudad-select-field.tsx @@ -1,7 +1,6 @@ 'use client' import { useState, useEffect, useRef, type CSSProperties, type ReactNode } from 'react' - -type GeoData = Record> +import { getGeoData, type GeoData } from '@/lib/geo-cache' interface CiudadSelectFieldProps { name: string @@ -23,6 +22,7 @@ export function CiudadSelectField({ error, }: CiudadSelectFieldProps) { const [geoData, setGeoData] = useState(null) + const [geoStatus, setGeoStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [open, setOpen] = useState(false) const [activeIdx, setActiveIdx] = useState(-1) const [dropdownStyle, setDropdownStyle] = useState({}) @@ -35,10 +35,9 @@ export function CiudadSelectField({ const prevDeptoRef = useRef(departamento) useEffect(() => { - fetch('/data/geo_paraguay_full.json') - .then((r) => r.json()) - .then(setGeoData) - .catch(() => {}) + getGeoData() + .then((data) => { setGeoData(data); setGeoStatus('ready') }) + .catch(() => setGeoStatus('error')) }, []) // Clear city when departamento changes so no orphan value remains @@ -130,6 +129,12 @@ export function CiudadSelectField({ autoComplete="off" className="text-short-input" 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)} onKeyDown={handleKeyDown} onFocus={() => { if (suggestions.length > 0) { computeDropdownStyle(); setOpen(true) } }} diff --git a/components/wizard-form.tsx b/components/wizard-form.tsx index 7810d44..99eaad7 100644 --- a/components/wizard-form.tsx +++ b/components/wizard-form.tsx @@ -70,7 +70,8 @@ export function WizardForm({ survey, schema }: WizardFormProps) { useEffect(() => { 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]) 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.' })) return } + saveDraft(survey.id, state.answers) setState((s) => next(s, schema)) } function handleBack() { + saveDraft(survey.id, state.answers) setState((s) => back(s, schema)) } diff --git a/lib/geo-cache.ts b/lib/geo-cache.ts new file mode 100644 index 0000000..2aa91e0 --- /dev/null +++ b/lib/geo-cache.ts @@ -0,0 +1,16 @@ +// departamento → ciudad → barrios[] +export type GeoData = Record> + +let geoData: GeoData | null = null +let geoPromise: Promise | null = null + +export async function getGeoData(): Promise { + 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 +}