My Role
- UX Engineering: flows, interaction patterns (map → tooltip → popup), empty/loaded states.
- Front-end Architecture & Implementation: vanilla JS, Google Maps API, data pipeline from CMS to UI.
- Internationalization: Arabic (RTL) layout support, Arabic-aware sorting, localized strings.
- Performance & Reliability: lazy list rendering, IntersectionObserver geocoding, mutation-based readiness gates, late hydration.
- Handover: documentation for editors; CMS field conventions.
Challenges
- Staggered content & third-party timing: CMS items and filter scripts don’t arrive at the same time; starting too early produces incomplete UIs.
- Heavy first paint: Rendering the entire list up front ballooned DOM size and slowed initial interaction.
- N+1 reverse-geocoding: Naively resolving every address triggers a burst of network calls and jank during scroll.
- Bilingual/RTL behavior: Names should sort naturally in Arabic; prefixes like “Dr.” / “د.” shouldn’t affect ordering. UI copy and address text must be localized.
- Late-arriving CMS items: Additional doctors can appear seconds after load; the UI must absorb them without a hard refresh.
- Image & content integrity: Photo src/srcset/sizes/alt need to stay in sync between CMS cards, list rows, and popups.
Solution
Robust readiness gates (race-condition proof)
I layered window load, Finsweet CMS events (cmsfilter, cmsload), a simple stable-count poll, and a deep MutationObserver signature. We only boot once data and attribute bindings are stable.
// Wait until doctor items + their key dataset bindings stop changing.
const waitForCMSStableDeep = async ({ quietMs = 900, maxWait = 15000 } = {}) => {
let lastSig = "", lastChange = Date.now();
const computeSig = () => {
const els = [...document.querySelectorAll(".doctor-item")];
const parts = els.map(el => `${el.dataset.name||""}|${el.dataset.country||""}|${el.dataset.city||""}`);
return `${els.length}::${parts.join("||")}`;
};
const changed = () => { const s = computeSig(); if (s !== lastSig) { lastSig = s; lastChange = Date.now(); } };
const obs = new MutationObserver(changed);
obs.observe(document.body, { childList: true, subtree: true });
return await new Promise(res => {
const iv = setInterval(() => (Date.now() - lastChange >= quietMs) && done(), 200);
const to = setTimeout(done, maxWait);
function done(){ clearInterval(iv); clearTimeout(to); obs.disconnect(); res(true); }
});
};
Lazy list rendering (fast first paint)
The list is built only after a country is selected. Before selection, we show a lightweight empty state and markers only. This keeps the initial DOM tiny and interaction responsive.
countrySelect.addEventListener("change", () => {
const selected = countrySelect.value;
if (!selected) { showEmptyList(); showListOverlay(); return; }
hideListOverlay();
buildDoctorList(filterByCountry(selected));
});
On-view geocoding + caching (no jank)
Addresses resolve only when list rows near the viewport intersect, and results are cached by lat/lng to avoid repeat lookups.
const geocodeCache = new Map();
const geocodeOnce = (lat, lng) => {
const key = `${lat},${lng}`;
if (geocodeCache.has(key)) return geocodeCache.get(key);
const p = new Promise(r => geocoder.geocode({ location:{lat,lng} },
(res, status) => r(status==="OK" && res?.[0] ? res[0].formatted_address : null)));
geocodeCache.set(key, p);
return p;
};
const io = new IntersectionObserver(entries => entries.forEach(e => {
if (!e.isIntersecting) return;
io.unobserve(e.target);
geocodeOnce(+e.target.dataset.lat, +e.target.dataset.lng)
.then(addr => e.target.textContent = addr || "Address not found");
}), { rootMargin: "200px 0px" });
Arabic-first sorting & localized UI
Arabic pages prefer Arabic display names and use Intl.Collator for natural sorting. We strip “Dr./Doctor/د./دكتور” prefixes so the alphabetical order reflects the actual name.
const IS_AR = /^ar\b/i.test(document.documentElement.lang) || document.documentElement.dir==="rtl";
const collator = new Intl.Collator(IS_AR ? ["ar","en"] : ["en","ar"], { sensitivity:"base", numeric:true });
const stripPrefix = s => s.trim().replace(/^\s*(?:dr\.?|doctor|د\.?|دكتور)\s*/i, "");
const sortKey = n => stripPrefix(n).toLocaleLowerCase();
// ...
const sorted = doctors.toSorted((a,b) => collator.compare(sortKey(a.name), sortKey(b.name)));
Localized strings (e.g., “Loading address…” → “جاري تحميل العنوان…”) keep status messaging native in both languages.
Late hydration window (seamless updates)
A short MutationObserver window (~8s) watches for new .doctor-items, normalizes them, and updates markers/options/list—without reload.
const beginLateHydration = (ms=8000) => {
const seen = new WeakSet([...document.querySelectorAll(".doctor-item")]);
const obs = new MutationObserver(muts => {
let changed=false;
muts.forEach(m => m.addedNodes.forEach(n => {
if (!(n instanceof Element)) return;
const els = n.matches(".doctor-item") ? [n] : n.querySelectorAll?.(".doctor-item");
els && els.forEach(el => { if(seen.has(el)) return; seen.add(el); addDoctorFromEl(el); changed=true; });
}));
if (changed) { rebuildCountryOptions(); filterAndRender(); }
});
obs.observe(document.body, { childList:true, subtree:true });
setTimeout(() => obs.disconnect(), ms);
};
Image & content integrity
Photos and srcset/sizes/alt are mirrored from CMS cards into list rows and popups to preserve quality and accessibility. Alt text falls back to the doctor’s name.
Process Overview
- Planning & Discovery: Clarified primary task (“find a nearby clinic”), countries/cities taxonomy, CMS fields, and i18n requirements.
- Design & Prototyping: Chose map-first with progressive disclosure: empty state → country → city → results. Defined tooltip vs. popup interaction and primary actions.
- Development: Vanilla JS architecture: harvest CMS → normalize → renderers (markers & list). Implemented readiness gates, geocode cache, and late hydration.
- Testing & Optimization: Cross-browser/device checks, RTL review, address resolution edge-cases, performance budget passes.
- Launch & Handover: CMS conventions, editor guide, and notes for future analytics (filter usage, directions CTR).
Project Images




