// Dubizzle Desk — scraper console // Adds Run + Settings to the design's viewer App. // State, filtering, view switching, tweaks; loads listings via /api/listings, // falls back to window.LISTINGS_DATA (data.mock.js) if the server isn't running. const { useState: useS, useMemo: useM, useEffect: useE, useCallback: useC, useRef: useR } = React; const DEFAULT_FILTERS = () => ({ priceMin: 0, priceMax: 250000, sizeMin: 0, sizeMax: 50000, beds: new Set(), baths: new Set(), balcony: false, waterView: false, furnished: false, hideDelisted: true, neighbourhoods: new Set(), types: new Set(), }); const DENSITY = "cozy"; // grid density was previously a tweak; cozy is the default const THEME_KEY = "dubz-theme"; function useTheme() { const [dark, setDark] = useS(() => { try { const stored = localStorage.getItem(THEME_KEY); if (stored === "dark") return true; if (stored === "light") return false; } catch {} return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; }); useE(() => { document.documentElement.dataset.theme = dark ? "dark" : "light"; try { localStorage.setItem(THEME_KEY, dark ? "dark" : "light"); } catch {} }, [dark]); return [dark, () => setDark((d) => !d)]; } // ---------- data loading ---------- function useDataHub() { // Available datasets on disk + active slug. const [datasets, setDatasets] = useS([]); const [activeSlug, setActiveSlug] = useS(null); // null = follow server's active const [data, setData] = useS(null); const [loading, setLoading] = useS(true); const [error, setError] = useS(null); const [missing, setMissing] = useS(false); // server reachable but no dataset yet const [missingSlug, setMissingSlug] = useS(null); const refreshDatasets = useC(async () => { try { const r = await fetch("/api/datasets", { cache: "no-store" }); if (!r.ok) throw new Error(`HTTP ${r.status}`); const j = await r.json(); setDatasets(j.datasets || []); if (activeSlug == null) setActiveSlug(j.active_slug || null); return j; } catch { return null; } }, [activeSlug]); const loadSlug = useC(async (slug) => { setLoading(true); setError(null); setMissing(false); setMissingSlug(null); try { const url = slug ? `/api/listings?slug=${encodeURIComponent(slug)}` : "/api/listings"; const res = await fetch(url, { cache: "no-store" }); if (res.status === 404) { let body = {}; try { body = await res.json(); } catch {} setMissing(true); setMissingSlug(body.slug || slug); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); const json = await res.json(); setData(json); if (json.slug) setActiveSlug(json.slug); } catch (e) { setError(String(e.message || e)); setData(null); } finally { setLoading(false); } }, []); // initial + whenever active slug changes useE(() => { refreshDatasets(); }, [refreshDatasets]); useE(() => { loadSlug(activeSlug); }, [activeSlug, loadSlug]); const reload = useC(async () => { await refreshDatasets(); await loadSlug(activeSlug); }, [refreshDatasets, loadSlug, activeSlug]); return { data, datasets, activeSlug, setActiveSlug, loading, error, missing, missingSlug, reload }; } // ---------- App ---------- function Splash() { return (
Dubizzle Desk
Starting up…
); } function App() { const [dark, toggleDark] = useTheme(); const [bootDone, setBootDone] = useS(false); useE(() => { const t = setTimeout(() => setBootDone(true), 900); return () => clearTimeout(t); }, []); const { data, datasets, activeSlug, setActiveSlug, loading, error, missing, missingSlug, reload } = useDataHub(); // state const [filters, setFilters] = useS(DEFAULT_FILTERS); const [search, setSearch] = useS(""); const [sort, setSort] = useS("updated"); const [view, setView] = useS("grid"); const [selectedId, setSelectedId] = useS(null); const [activeMarkerId, setActiveMarkerId] = useS(null); const [runOpen, setRunOpen] = useS(false); const reset = useC(() => setFilters(DEFAULT_FILTERS()), []); // listings ref for the filter pipeline (empty until first load) const listings = data?.listings || []; // filtering const filtered = useM(() => { const q = search.trim().toLowerCase(); return listings .filter((l) => { if (filters.hideDelisted && l.status === "delisted") return false; if (l.price < filters.priceMin || l.price > filters.priceMax) return false; if (l.size_sqft < filters.sizeMin || l.size_sqft > filters.sizeMax) return false; if (filters.beds.size) { const match = [...filters.beds].some((b) => b === 6 ? l.bedrooms >= 6 : l.bedrooms === b); if (!match) return false; } if (filters.baths.size) { const match = [...filters.baths].some((b) => b === 6 ? l.bathrooms >= 6 : l.bathrooms === b); if (!match) return false; } if (filters.balcony && l.balcony !== "Yes") return false; if (filters.waterView && l.water_view !== "Yes") return false; if (filters.furnished && !/Furnished/i.test(l.furnished)) return false; if (filters.neighbourhoods.size && !filters.neighbourhoods.has(l.neighbourhood)) return false; if (filters.types.size && !filters.types.has(l.property_type)) return false; if (q) { const blob = `${l.title} ${l.ref_id} ${l.neighbourhood} ${l.location}`.toLowerCase(); if (!blob.includes(q)) return false; } return true; }) .sort((a, b) => { switch (sort) { case "price_asc": return a.price - b.price; case "price_desc": return b.price - a.price; case "size_asc": return a.size_sqft - b.size_sqft; case "size_desc": return b.size_sqft - a.size_sqft; case "newest": return new Date(b.first_seen) - new Date(a.first_seen); case "updated": default: return new Date(b.last_updated) - new Date(a.last_updated); } }); }, [listings, filters, search, sort]); const selected = useM( () => selectedId ? listings.find((l) => l.id === selectedId) : null, [selectedId, listings] ); const hasData = !!data; const [route, setRoute] = useS("home"); // home | listings | settings const [lastStats, setLastStats] = useS(null); const [runPhase, setRunPhase] = useS("idle"); // idle | running | success | error const openRun = () => setRunOpen(true); const openSettings = () => setRoute("settings"); const onRunDone = (stats) => { if (stats) setLastStats(stats); setRunPhase(stats ? "success" : "error"); reload(); }; // splash gate must come AFTER every hook call (React #310 otherwise) if (!bootDone) return ; return (
{route === "home" && ( setRoute("listings")} /> )} {route === "listings" && (
{!hasData ? ( ) : view === "grid" ? ( <>
Showing {filtered.length} {filtered.length === 1 ? "listing" : "listings"} {search && <> matching "{search}"} {filters.hideDelisted && · delisted hidden}
Photos and prices reflect last scrape ({new Date(data.generated_at).toLocaleDateString("en-GB", {day:"numeric", month:"short"})}).
setSelectedId(l.id)} /> ) : ( setSelectedId(l.id)} activeId={activeMarkerId} setActiveId={setActiveMarkerId} /> )}
)} {route === "settings" && ( )} {selected && setSelectedId(null)}/>} {runOpen && ( setRunOpen(false)} onStart={() => setRunPhase("running")} onDone={(stats) => onRunDone(stats)} /> )}
); } async function openFolder() { try { const r = await fetch("/api/open-folder", { method: "POST" }); if (!r.ok) throw new Error(`HTTP ${r.status}`); } catch (e) { alert("Couldn't open folder: " + (e.message || e)); } } // ---- empty state ----------------------------------------------------- function NoDataState({ loading, missing, missingSlug, error, datasets, onRun, onSettings, onPickDataset }) { if (loading) { return (
Loading listings…
); } if (error) { return (
Couldn't reach the local server.
{error}
); } // missing dataset (404) return (
No scrape on disk yet
{missingSlug ? <>Nothing saved for {missingSlug}. : "Your active criteria has no data yet."} {" "}Set what you want to pull in Settings, then click Run scrape.
{datasets && datasets.length > 0 && (
Or load an existing dataset:
{datasets.map((d) => ( ))}
)}
); } // ---- nav sidebar ----------------------------------------------------- function NavSidebar({ route, setRoute, dark, onToggleDark, runPhase, listingsBadge, offline }) { const item = (key, label, icon, badge) => ( ); const status = runPhase === "running" ? { dot: "running", text: "Running scrape…" } : runPhase === "success" ? { dot: "ok", text: "Last run OK" } : runPhase === "error" ? { dot: "err", text: "Run failed" } : offline ? { dot: "err", text: "Server offline" } : { dot: "idle", text: "Ready" }; return ( ); } // ---- Home screen ----------------------------------------------------- function HomeScreen({ data, hasData, loading, missing, missingSlug, error, datasets, activeSlug, lastStats, onPick, onRun, onSettings, onOpenFolder, onGoListings }) { const cur = data?.criteria; const ds = (slug) => datasets.find((d) => d.slug === slug); const active = ds(activeSlug); return (
Home
{hasData ? <>Tracking {(data.listings || []).length.toLocaleString()} listings · last scraped {new Date(data.generated_at).toLocaleString("en-GB", { day:"numeric", month:"short", hour:"2-digit", minute:"2-digit" })} : "No dataset loaded yet — pick one below or run a scrape."}

Last run

{lastStats ? (
) : hasData ? (
l.water_view === "Yes").length}/> l.balcony === "Yes").length}/> l.status === "delisted").length}/>
) : (
Run a scrape to populate stats.
)}

Current criteria

{cur ? (
Emirate
{cur.site}
Category
{cur.category}
Min size
{(cur.min_size_sqft || 0).toLocaleString()} sqft
Max price
{(cur.max_price_aed || 0).toLocaleString()} AED
) : (
No criteria set yet — open Settings.
)}

Datasets on disk

{datasets.length ? (
{datasets.map((d) => ( ))}
) : (
{loading ? "Loading…" : "Nothing on disk yet. Click Run scrape to fetch your first dataset."}
)}
); } function Stat({ k, v }) { return (
{k}
{(v ?? 0).toLocaleString()}
); } // ---- Settings screen (full page; replaces the modal) ---------------- function SettingsScreen({ onSaved, onRun }) { const flushRef = useR(null); const runWithFlush = async () => { // make absolutely sure pending changes are saved to disk before the // scraper reads config.yaml — otherwise the scrape uses stale criteria. if (flushRef.current) { try { await flushRef.current(); } catch {} } onRun(); }; return (
Settings
Edits auto-save to config.yaml. Click Run scrape to apply.
); } const root = ReactDOM.createRoot(document.getElementById("app-root")); root.render();