// 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 (
);
}
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 .
Open settings
Run scrape
{datasets && datasets.length > 0 && (
Or load an existing dataset:
{datasets.map((d) => (
onPickDataset(d.slug)}>
{d.site} · {(d.category || "").replace("Property for ", "").toLowerCase()}
{(d.count || 0).toLocaleString()}
))}
)}
);
}
// ---- nav sidebar -----------------------------------------------------
function NavSidebar({ route, setRoute, dark, onToggleDark, runPhase, listingsBadge, offline }) {
const item = (key, label, icon, badge) => (
setRoute(key)}>
{icon}
{label}
{badge != null && {badge.toLocaleString()} }
);
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 (
Dubizzle Desk
Property tracker
{item("home", "Home", )}
{item("listings", "Listings", , listingsBadge)}
{item("settings", "Settings", )}
{status.text}
{dark ? : }
);
}
// ---- 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."}
Show files
Settings
Run scrape
Last run
{lastStats ? (
) : hasData ? (
l.water_view === "Yes").length}/>
l.balcony === "Yes").length}/>
l.status === "delisted").length}/>
) : (
Run a scrape to populate stats.
)}
Browse listings
Open data folder
Current criteria
{cur ? (
Min size
{(cur.min_size_sqft || 0).toLocaleString()} sqft
Max price
{(cur.max_price_aed || 0).toLocaleString()} AED
) : (
No criteria set yet — open Settings.
)}
Edit criteria
Datasets on disk
{datasets.length ? (
{datasets.map((d) => (
{ onPick(d.slug); onGoListings(); }}>
{d.site} · {(d.category || "").replace("Property for ", "").toLowerCase()}
≥{(d.min_size_sqft || 0).toLocaleString()} sqft · ≤{((d.max_price_aed || 0) / 1000).toFixed(0)}k AED
{d.generated_at && <> · scraped {new Date(d.generated_at).toLocaleDateString("en-GB", { day:"numeric", month:"short" })}>}
{(d.count || 0).toLocaleString()}
))}
) : (
{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.
Run scrape
);
}
const root = ReactDOM.createRoot(document.getElementById("app-root"));
root.render( );