← back to Commercialrealestate

public/crcp-scope.js

170 lines

/* crcp-scope.js — per-city scoping for <city>.crcp.agentabrams.com (Phase 1, front-end only).
 *
 * The grid loads a static data/ranked.json (prod is DB-less). To scope a city subdomain WITHOUT
 * editing the big shared index.html, this script — injected in <head> by serve.js, BEFORE the app's
 * inline boot — wraps window.fetch: when the app pulls ranked.json (and /api/condos), we filter the
 * listings to the host's city. The app then renders the scoped set as if the snapshot only held that
 * city. Unscoped hosts (base crcp., recities., localhost, IPs) are a no-op — the app runs untouched.
 *
 * Reversible: removing the two injected <script> tags (serve.js) fully disables this. Guarded: only
 * URLs containing ranked.json / api/condos are touched; anything else passes straight through.  */
(function () {
  if (!window.CRCP_CITIES) return;                          // cities table must load first
  var CITY = window.CRCP_CITIES.cityFromHost(location.hostname);
  if (!CITY) return;                                        // base crcp / recities / localhost -> no scope

  var scoped = { ranked: 0, total: 0, arrBefore: 0, arrAfter: 0, arrMatched: false };

  // Property-level grids that carry a flat top-level .city and should be city-filtered on a subdomain.
  // (rent-rolls is intentionally excluded — its city is nested and it's a single sample row.)
  var SCOPED_ARRAYS = [
    { re: /\/api\/closed-sales/, key: "rows" },
    { re: /\/api\/direct-listings/, key: "listings" },
    { re: /\/data\/listings\.json/, key: "listings" },
    { re: /\/data\/valley-pools\.json/, key: "homes" }
    // fha-loans.json is intentionally NOT scoped: it's ~93% non-California NATIONAL HUD
    // reference data (498/7404 rows mention CA), so per-city scoping yields 0 rows on
    // most CRCP cities and a misleading "no listings here" empty-state. Treated statewide
    // like the broker/lender directories instead (see DIRECTORY_PAGES below).
  ];

  // Some property feeds ship item.city === null but embed the city in item.address
  // ("STREET, CITY, ST ZIP"). Derive it: the comma-segment right before the "ST ZIP"
  // tail is the city. No parseable tail -> null (row fails closed = drops on a city host).
  function deriveCity(item) {
    if (item && item.city != null && String(item.city).replace(/\s/g, "") !== "") return item.city;
    if (!item || !item.address) return null;
    var parts = String(item.address).split(",");
    for (var j = 0; j < parts.length; j++) parts[j] = parts[j].replace(/^\s+|\s+$/g, "");
    parts = parts.filter(function (s) { return !!s; });
    for (var k = parts.length - 1; k >= 1; k--) {
      if (/^[A-Za-z]{2}\s+\d{5}/.test(parts[k])) return parts[k - 1] || null;
    }
    return null;
  }

  // CRCP is a CALIFORNIA-ONLY product, but the Crexi source feeds are NATIONAL — so a bare
  // city-name match (sameCity) pulls same-named out-of-state cities onto a CA subdomain
  // (georgetown.crcp was 11/11 Texas, chester = VA/IL/SC). Require positive California evidence:
  // a crexi '/california-' source slug, a CA zip (90000–96199), or ', CA' in the address.
  // A provably-other-state signal, or no signal at all, fails closed (a CA-only product must
  // not show unconfirmable-state inventory). Returns true = keep, false = drop.
  function isCalifornia(item) {
    if (!item) return false;
    var src = String(item.source == null ? "" : item.source);
    if (/\/properties\/\d+\/california-/i.test(src)) return true;                  // crexi CA slug (anchored to the state token, not a street named 'California')
    if (/crexi\.com\/properties\/\d+\/[a-z]{2,}-/i.test(src)) return false;        // crexi non-CA slug -> not CA
    var z = parseInt(String(item.zip == null ? "" : item.zip).slice(0, 5), 10);
    if (z >= 90000 && z <= 96199) return true;                                     // CA zip
    if (z >= 500 && z <= 99999) return false;                                      // a real non-CA zip -> not CA
    var addr = String(item.address == null ? "" : item.address);
    if (/,\s*CA\b/i.test(addr)) return true;                                       // ', CA' in address
    if (/,\s*[A-Za-z]{2}\b/.test(addr)) return false;                             // some other 2-letter state -> not CA
    return false;                                                                  // no signal -> fail closed
  }

  // Re-run the banner/empty-state the instant a scoped fetch resolves (event-driven, not a
  // fixed timer) so the empty-state never depends on data arriving before a magic 2500ms.
  function schedulePaint() { setTimeout(function () { if (document.body) paintBanner(); }, 0); }

  var _fetch = window.fetch;
  window.fetch = function (input, init) {
    var url = (typeof input === "string") ? input : (input && input.url) || "";
    var p = _fetch.apply(this, arguments);
    if (/ranked\.json/.test(url)) {
      return p.then(function (r) {
        return r.clone().json().then(function (d) {
          if (d && Array.isArray(d.ranked)) {
            scoped.total = d.ranked.length;
            d.ranked = d.ranked.filter(function (x) { return window.CRCP_CITIES.sameCity(x.city, CITY); });
            scoped.ranked = d.ranked.length;
            if (d.meta) d.meta.market = CITY.label;          // sub-header shows the scoped market name
            schedulePaint();
          }
          return new Response(JSON.stringify(d), { status: 200, headers: { "Content-Type": "application/json" } });
        }).catch(function () { return r; });                 // parse failure -> pass original through
      });
    }
    if (/\/api\/condos/.test(url)) {                         // keep the in-grid condo overlay city-consistent
      return p.then(function (r) {
        return r.clone().json().then(function (d) {
          if (d && Array.isArray(d.condos)) d.condos = d.condos.filter(function (c) { return window.CRCP_CITIES.sameCity(c.city, CITY); });
          return new Response(JSON.stringify(d), { status: 200, headers: { "Content-Type": "application/json" } });
        }).catch(function () { return r; });
      });
    }
    for (var i = 0; i < SCOPED_ARRAYS.length; i++) {
      var cfg = SCOPED_ARRAYS[i];
      if (cfg.re.test(url)) {
        return p.then(function (key) {
          return function (r) {
            return r.clone().json().then(function (d) {
              if (d && Array.isArray(d[key])) {
                scoped.arrBefore = d[key].length;
                scoped.arrMatched = true;
                d[key] = d[key].filter(function (item) { return isCalifornia(item) && window.CRCP_CITIES.sameCity(deriveCity(item), CITY); });
                scoped.arrAfter = d[key].length;
                schedulePaint();
              }
              return new Response(JSON.stringify(d), { status: 200, headers: { "Content-Type": "application/json" } });
            }).catch(function () { return r; });               // parse failure -> pass original through
          };
        }(cfg.key));
      }
    }
    return p;
  };

  // Scope banner + empty-state, injected once the DOM is ready (does not touch app JS).
  var EMPTY_CSS = "margin:22px;padding:18px 20px;border:1px solid #24487e;border-radius:12px;" +
    "background:#0f1b30;color:#c7d5ea;font:14px/1.5 -apple-system,sans-serif;text-align:center";
  var DIRECTORY_PAGES = ["/brokers.html", "/broker-grid.html", "/firms.html", "/lenders.html",
    "/loan-officers.html", "/lending.html", "/gov-agents.html", "/licensed-agents.html",
    "/licensed.html", "/residential-brokers.html", "/linkedin.html",
    "/fha-leads.html", "/deals-flow.html", "/fha-loans.html"];   // fha = national HUD reference, statewide

  function emptyNote(msg) {
    var el = document.createElement("div");
    el.setAttribute("data-crcp-empty", "1");
    el.style.cssText = EMPTY_CSS;
    el.innerHTML = msg + ' <a href="https://crcp.agentabrams.com/" style="color:#9ecbff">Browse all markets &rarr;</a>';
    return el;
  }

  // Called twice (DOMContentLoaded + a 2500ms re-check) because the grid data loads async.
  // Banner is created once; the empty-state is re-evaluated on EVERY call (its own
  // [data-crcp-empty] guard prevents doubling) so the delayed re-check can add it after
  // the fetch resolves — the early-return used to sit above this and killed it.
  function paintBanner() {
    var isDirectory = DIRECTORY_PAGES.indexOf(location.pathname) !== -1;
    var bar = document.getElementById("crcp-scope-banner");
    if (!bar) {
      bar = document.createElement("div");
      bar.id = "crcp-scope-banner";
      bar.style.cssText = "position:sticky;top:0;z-index:9999;display:flex;gap:12px;align-items:center;" +
        "justify-content:center;padding:9px 16px;background:#12305a;color:#eaf1fb;font:13px/1.4 -apple-system," +
        "BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;border-bottom:1px solid #24487e";
      var n = scoped.ranked;
      bar.innerHTML =
        '<span>Scoped to <b>' + CITY.label + '</b>' + (n ? ' · ' + n + ' listing' + (n === 1 ? '' : 's') : '') + '</span>' +
        (isDirectory ? '<span style="color:#8aa2c4;font-size:12px">· directories are statewide</span>' : '') +
        '<a href="https://crcp.agentabrams.com/" style="color:#9ecbff;text-decoration:none;border:1px solid #2f5da3;border-radius:8px;padding:3px 10px">View all markets →</a>' +
        '<a href="https://recities.crcp.agentabrams.com/" style="color:#9ecbff;text-decoration:none;border:1px solid #2f5da3;border-radius:8px;padding:3px 10px">All cities</a>';
      document.body.insertBefore(bar, document.body.firstChild);
    }

    if (document.querySelector("[data-crcp-empty]")) return;   // empty-state already shown, don't double
    if (scoped.ranked === 0 && scoped.total > 0) {             // main grid scoped but city has none
      bar.insertAdjacentElement("afterend", emptyNote("No CRCP listings in <b>" + CITY.label + "</b> yet."));
    } else if (scoped.arrMatched && scoped.arrAfter === 0 && !isDirectory) {
      // fires whether the city filtered a populated grid to 0 OR the source array is itself
      // empty (arrBefore===0) — a blank scoped grid must always explain itself, never render bare.
      bar.insertAdjacentElement("afterend", emptyNote("No listings in <b>" + CITY.label + "</b> for this view yet."));
    }
  }
  // ranked.json loads async; re-check the empty-state shortly after boot so the count is accurate.
  function ready() { paintBanner(); setTimeout(paintBanner, 2500); }
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", ready);
  else ready();
})();