const { useState, useEffect, useRef, useCallback } = React;
// ── helpers ──────────────────────────────────────────────────────────────────
function fmt(n) {
if (n == null) return "—";
return Number(n).toLocaleString("es-AR");
}
// ── ListingCard ───────────────────────────────────────────────────────────────
function ListingCard({ listing, onSimilar }) {
return (
{listing.image_url
?

:
Sin foto
}
{listing.barrio || ""}
{listing.propiedad && (
{listing.propiedad}
)}
USD {fmt(listing.price)}
{(listing.surface || listing.m2c) && (
{" · "}{listing.surface || listing.m2c} m²
)}
{listing.id && (
)}
);
}
function ListingGrid({ listings, onSimilar, emptyMsg = "Sin resultados." }) {
if (!listings.length) return {emptyMsg}
;
return (
{listings.map((l, i) => )}
);
}
// ── PriceChart ────────────────────────────────────────────────────────────────
function PriceChart({ data }) {
const ref = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
if (!data || !ref.current) return;
if (chartRef.current) chartRef.current.destroy();
chartRef.current = new Chart(ref.current, {
type: "line",
data: {
labels: data.surfaces,
datasets: [
{ label: "Mediana", data: data.median, borderColor: "#1a1a2e", tension: 0.3, pointRadius: 0 },
{ label: "md+std", data: data.md_plus_std, borderColor: "#7986cb", borderDash: [4,4], tension: 0.3, pointRadius: 0 },
{ label: "md-std", data: data.md_minus_std,borderColor: "#7986cb", borderDash: [4,4], tension: 0.3, pointRadius: 0 },
{ label: "Mín", data: data.min, borderColor: "#ccc", borderDash: [2,4], tension: 0.3, pointRadius: 0 },
{ label: "Máx", data: data.max, borderColor: "#ccc", borderDash: [2,4], tension: 0.3, pointRadius: 0 },
],
},
options: {
responsive: true,
plugins: { legend: { position: "bottom" } },
scales: {
x: { title: { display: true, text: "m² cubiertos" } },
y: { title: { display: true, text: "Precio USD" } },
},
},
});
return () => chartRef.current?.destroy();
}, [data]);
return ;
}
// ── SimilarPortfolio ──────────────────────────────────────────────────────────
function SimilarPortfolio({ listing, onBack }) {
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [current, setCurrent] = useState(listing);
const load = useCallback(async (l) => {
setLoading(true); setError(null);
try {
const res = await fetch(`/similar/${l.id}?top_k=20`);
if (!res.ok) throw new Error(`Error ${res.status}`);
setResults(await res.json());
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(listing); }, [listing]);
const handleSimilar = (l) => { setCurrent(l); load(l); };
return (
Similares a: {current.title || current.id}
{loading &&
Buscando similares…
}
{error &&
{error}
}
{!loading && !error && (
)}
);
}
// ── Constants ─────────────────────────────────────────────────────────────────
const BARRIOS = ["", "nuñez", "colegiales", "villa crespo", "villa urquiza", "san isidro", "paternal"];
const PROPIEDADES = [
{ value: "", label: "Tipo" },
{ value: "D", label: "Depto" },
{ value: "C", label: "Casa" },
{ value: "P", label: "PH" },
];
// ── Main search panel ─────────────────────────────────────────────────────────
function SearchPanel({ onSimilar }) {
const [query, setQuery] = useState("");
const [filters, setFilters] = useState({
barrio: "", propiedad: "",
min_price: "", max_price: "",
min_m2c: "", max_m2c: "",
min_antiguedad: "", max_antiguedad: "",
});
const [results, setResults] = useState([]);
const [chartData, setChartData] = useState(null);
const [total, setTotal] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const setFilter = (k) => (e) => setFilters(f => ({ ...f, [k]: e.target.value }));
const numericFilters = () => {
const n = {};
["min_price","max_price","min_m2c","max_m2c","min_antiguedad","max_antiguedad"].forEach(k => {
if (filters[k] !== "") n[k] = Number(filters[k]);
});
if (filters.barrio) n.barrio = filters.barrio;
if (filters.propiedad) n.propiedad = filters.propiedad;
return n;
};
const handleSearch = useCallback(async () => {
setLoading(true); setError(null); setChartData(null);
try {
if (query.trim()) {
// NLP search — filters applied server-side
const payload = { query, top_k: 20, ...numericFilters() };
const res = await fetch("/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) throw new Error(`Error ${res.status}`);
const results = await res.json();
setResults(results);
setTotal(results.length);
} else {
// No query — use filter endpoint (also shows chart)
const body = new URLSearchParams();
Object.entries(filters).forEach(([k, v]) => v !== "" && body.append(k, v));
const res = await fetch("/filter_json", { method: "POST", body });
if (!res.ok) throw new Error(`Error ${res.status}`);
const data = await res.json();
setChartData(data);
setResults(data.matches || []);
setTotal(data.total_matches);
}
} catch (e) { setError(e.message); }
finally { setLoading(false); }
}, [query, filters]);
const onKey = (e) => e.key === "Enter" && handleSearch();
return (
{/* Search bar + filters row */}
{error &&
{error}
}
{total != null && (
{total} resultado{total !== 1 ? "s" : ""}
{query.trim() ? " · filtros aplicados sobre búsqueda semántica" : ""}
)}
{/* Chart (only shown when no NLP query) */}
{chartData && (
)}
{/* Results */}
);
}
// ── App ───────────────────────────────────────────────────────────────────────
function App() {
const [view, setView] = useState("main");
const [similarListing, setSimilarListing] = useState(null);
const handleSimilar = (listing) => {
setSimilarListing(listing);
setView("similar");
window.scrollTo(0, 0);
};
if (view === "similar" && similarListing) {
return (
setView("main")} />
);
}
return ;
}
ReactDOM.createRoot(document.getElementById("root")).render();