// Header, FilterRail, ListingCard, Grid // Exports to window for consumption by app.jsx const { useState, useMemo, useEffect, useRef, useCallback } = React; // --- icons (inline svg) --- const I = { Search: (p) => ( ), Grid: (p) => ( ), Map: (p) => ( ), Camera: (p) => ( ), Pin: (p) => ( ), Bed: (p) => ( ), Bath: (p) => ( ), Area: (p) => ( ), ArrowDown: (p) => ( ), ArrowUp: (p) => ( ), ChevronLeft: (p) => ( ), ChevronRight: (p) => ( ), Close: (p) => ( ), Ext: (p) => ( ), Play: (p) => ( ), Settings: (p) => ( ), Refresh: (p) => ( ), Check: (p) => ( ), Sun: (p) => ( ), Moon: (p) => ( ), Home: (p) => ( ), }; // --- formatters --- const fmtPrice = (n) => `AED ${n.toLocaleString("en-US")}`; const fmtPriceShort = (n) => { if (n >= 1000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`; return `${n}`; }; const fmtSize = (n) => `${n.toLocaleString("en-US")} sqft`; const fmtDate = (s) => { if (!s) return ""; const d = new Date(s); return d.toLocaleDateString("en-GB", { day: "numeric", month: "short" }); }; const daysAgo = (s) => { if (!s) return 0; const ms = Date.now() - new Date(s).getTime(); return Math.max(0, Math.floor(ms / 86400000)); }; const relDate = (s) => { const d = daysAgo(s); if (d === 0) return "today"; if (d === 1) return "yesterday"; if (d < 7) return `${d}d ago`; if (d < 30) return `${Math.floor(d / 7)}w ago`; return fmtDate(s); }; // price_history string: "2026-05-28:175000->160000" -> [{date, from, to}] const parsePriceHistory = (s) => { if (!s) return []; return s.split(";").map((entry) => { const m = entry.trim().match(/^(\d{4}-\d{2}-\d{2}):(\d+)->(\d+)$/); if (!m) return null; return { date: m[1], from: parseInt(m[2], 10), to: parseInt(m[3], 10) }; }).filter(Boolean); }; // -------------------------------------------------------------- // DATASET PICKER (header dropdown) // -------------------------------------------------------------- function DatasetPicker({ datasets, activeSlug, onPick }) { const [open, setOpen] = useState(false); const wrapRef = useRef(null); useEffect(() => { if (!open) return; const onDoc = (e) => { if (!wrapRef.current?.contains(e.target)) setOpen(false); }; document.addEventListener("mousedown", onDoc); return () => document.removeEventListener("mousedown", onDoc); }, [open]); const active = datasets.find((d) => d.slug === activeSlug) || datasets[0]; if (!datasets.length) { return (
dataset none
); } const label = (d) => { const cat = (d.category || "").replace("Property for ", "").toLowerCase(); return `${d.site || "?"} · ${cat || "?"}`; }; return (
{open && (
{datasets.map((d) => ( ))}
Change scrape criteria in Settings, then click Run scrape to add new datasets.
)}
); } // -------------------------------------------------------------- // HEADER // -------------------------------------------------------------- function Header({ data, results, search, setSearch, view, setView, sort, setSort, onRun, onSettings, dark, onToggleDark, offline, datasets, activeSlug, onPickDataset, disabled }) { const generated_at = data?.generated_at; const totalListings = data?.listings?.length || 0; const syncTime = useMemo(() => { if (!generated_at) return null; const d = new Date(generated_at); return d.toLocaleString("en-GB", { hour: "2-digit", minute: "2-digit" }); }, [generated_at]); const inputRef = useRef(null); useEffect(() => { const onKey = (e) => { if (e.key === "/" && document.activeElement?.tagName !== "INPUT") { e.preventDefault(); inputRef.current?.focus(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, []); return (
Dubizzle Desk Local property tracker
{data ? ( <>
{results.toLocaleString()}of {totalListings.toLocaleString()}
synced {syncTime} ) : ( no data yet )}
{offline && offline}
setSearch(e.target.value)} placeholder={disabled ? "No dataset loaded" : "Search by title, ref ID, neighbourhood or location…"} /> /
); } // -------------------------------------------------------------- // FILTER RAIL // -------------------------------------------------------------- function DualRange({ min, max, step, valueMin, valueMax, onChange, fmt }) { const pctMin = ((valueMin - min) / (max - min)) * 100; const pctMax = ((valueMax - min) / (max - min)) * 100; return (
onChange(Math.min(+e.target.value, valueMax - step), valueMax)} /> onChange(valueMin, Math.max(+e.target.value, valueMin + step))} />
{fmt(min)} {fmt(max)}
); } function ChipRow({ items, selected, onToggle, valueMap }) { return (
{items.map((it) => ( ))}
); } function Toggle({ label, value, onChange }) { return (
onChange(!value)}> {label}
); } function Checklist({ items, counts, selected, onToggle }) { return (
{items.map((it) => (
onToggle(it)} >
{it} {counts[it] || 0}
))}
); } function FilterRail({ filters, setFilters, reset, resultCount, listings, disabled }) { if (disabled) { return ( ); } const f = filters; const update = (patch) => setFilters({ ...f, ...patch }); const hoodCounts = useMemo(() => { const m = {}; listings.forEach((l) => { m[l.neighbourhood] = (m[l.neighbourhood] || 0) + 1; }); return m; }, [listings]); const typeCounts = useMemo(() => { const m = {}; listings.forEach((l) => { m[l.property_type] = (m[l.property_type] || 0) + 1; }); return m; }, [listings]); const hoodList = useMemo(() => Object.keys(hoodCounts).sort(), [hoodCounts]); const typeList = useMemo(() => Object.keys(typeCounts).sort(), [typeCounts]); const toggleSet = (key, val) => { const next = new Set(f[key]); if (next.has(val)) next.delete(val); else next.add(val); update({ [key]: next }); }; return ( ); } // -------------------------------------------------------------- // LISTING CARD // -------------------------------------------------------------- function badgeKind(amenity) { const a = amenity.toLowerCase(); if (a.includes("sea") || a.includes("water")) return "water"; if (a.includes("furnished")) return "furnished"; if (a.includes("private pool") || a.includes("smart") || a.includes("concierge") || a.includes("private lift")) return "premium"; return "neutral"; } function pickBadges(listing) { const out = []; const seen = new Set(); const pushIf = (text, kind) => { if (seen.has(text)) return; seen.add(text); out.push({ text, kind }); }; if (listing.water_view === "Yes") pushIf("Sea view", "water"); if (listing.balcony === "Yes") pushIf("Balcony", "neutral"); if (listing.furnished === "Furnished") pushIf("Furnished", "furnished"); if (listing.furnished === "Semi-Furnished") pushIf("Semi-furnished", "furnished"); listing.amenities.slice(0, 6).forEach((a) => { const k = badgeKind(a); if (k === "premium") pushIf(a, "premium"); }); return out.slice(0, 4); } function PriceDelta({ history }) { if (!history || !history.length) return null; const last = history[history.length - 1]; const up = last.to > last.from; const pct = Math.round(((last.to - last.from) / last.from) * 100); return ( {up ? : } {Math.abs(pct)}% {fmtPriceShort(last.from)} → {fmtPriceShort(last.to)} on {fmtDate(last.date)} ); } function ListingCard({ listing, onOpen }) { const [imgOk, setImgOk] = useState(true); const history = useMemo(() => parsePriceHistory(listing.price_history), [listing.price_history]); const badges = useMemo(() => pickBadges(listing), [listing]); const isDelisted = listing.status === "delisted"; return (
onOpen(listing)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter") onOpen(listing); }} >
{isDelisted &&
Delisted
} {listing.property_type} {imgOk ? ( {listing.title} setImgOk(false)} /> ) : (
[ photo unavailable ]
)} {listing.photos.length}
{fmtPriceShort(listing.price)} AED/yr
{listing.size_sqft.toLocaleString()} sqft {listing.bedrooms} {listing.bathrooms}
{badges.map((b, i) => ( {b.text} ))}
{listing.neighbourhood} listed {relDate(listing.first_seen)}
); } // -------------------------------------------------------------- // GRID // -------------------------------------------------------------- function Grid({ listings, density, onOpen }) { if (!listings.length) { return (

No listings match these filters

Try widening price, size, or clearing neighbourhoods.

); } return (
{listings.map((l) => ( ))}
); } Object.assign(window, { I, Header, FilterRail, Grid, ListingCard, fmtPrice, fmtPriceShort, fmtSize, fmtDate, relDate, parsePriceHistory, pickBadges });