// 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}
);
}
// --------------------------------------------------------------
// 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 (
);
}
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 ? (

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
});