← back to Rentv V1

contrib/contractors/widget.html

139 lines

<!-- =========================================================================
     RENTV contrib — "Licensed contractors in {market}" widget  (TK-10488)
     -------------------------------------------------------------------------
     A SELF-CONTAINED, drop-in widget for a CRE deal news story. Paste this
     whole block into a story template (or include the <script> from a partial).
     It calls the contrib router's /api/contractors-for-market endpoint and
     renders a TEXT-ONLY list (name + trade + phone). No external links, no
     re-hosted assets, honoring the RENTV no-rentv.com-link-out rule.

     USAGE (two ways):

     1) Auto-init from data attributes — drop the markup on the page:
          <div class="rentv-contractors"
               data-county="Los Angeles" data-city="Los Angeles"
               data-limit="6"></div>
        ...then include this file once (or its <script>) anywhere after it.

     2) Programmatic — call the global:
          RentvContractors.render(document.getElementById('box'),
            { county: 'Los Angeles', city: 'Los Angeles', limit: 6 });

     The endpoint base defaults to same-origin '/contrib/contractors'; override
     with data-endpoint="..." or the { endpoint } option.
     ========================================================================= -->
<style>
  .rentv-contractors { font: 14px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif; color: #1a1a1a; }
  .rentv-contractors__title { font-size: 15px; font-weight: 700; margin: 0 0 .5em; letter-spacing: .01em; }
  .rentv-contractors__list { list-style: none; margin: 0; padding: 0; }
  .rentv-contractors__item { padding: .55em 0; border-top: 1px solid #e6e6e6; }
  .rentv-contractors__item:first-child { border-top: none; }
  .rentv-contractors__name { font-weight: 600; }
  .rentv-contractors__trade { color: #555; }
  .rentv-contractors__phone { color: #333; }
  .rentv-contractors__meta { color: #888; font-size: 12px; }
  .rentv-contractors__asof { color: #999; font-size: 11px; margin-top: .2em; font-style: italic; }
  .rentv-contractors__attr { color: #999; font-size: 11px; margin-top: .6em; }
  .rentv-contractors[hidden] { display: none; }
</style>

<script>
(function () {
  'use strict';
  if (window.RentvContractors) return; // idempotent — safe to include more than once

  function esc(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
      return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
    });
  }

  function marketLabel(m) {
    if (!m) return 'this market';
    return m.city || m.county || 'this market';
  }

  function renderList(el, data) {
    var contractors = (data && data.contractors) || [];
    if (!contractors.length) { el.hidden = true; return; } // nothing to show -> hide, story stays clean

    var label = marketLabel(data.market);
    // DATE EVERYTHING — every entry carries the CSLB freshness date + a text-only
    // "verify at cslb.ca.gov" (no link-out, honoring the RENTV no-link-out rule).
    var asOf = (data && data.source_as_of) ? String(data.source_as_of) : 'unknown';
    var verify = (data && data.verify_url) ? String(data.verify_url) : 'cslb.ca.gov';
    var asOfLine = 'CSLB data as of ' + esc(asOf) + ' &mdash; verify at ' + esc(verify);

    var rows = contractors.map(function (c) {
      var bits = [];
      bits.push('<span class="rentv-contractors__name">' + esc(c.name) + '</span>');
      if (c.trade) bits.push('<span class="rentv-contractors__trade"> &middot; ' + esc(c.trade) + '</span>');
      var phone = c.phone
        ? '<div class="rentv-contractors__phone">' + esc(c.phone) + '</div>'
        : '';
      var meta = [];
      if (c.city) meta.push(esc(c.city));
      if (c.license_no) meta.push('Lic. ' + esc(c.license_no));
      if (c.license_status) meta.push(esc(c.license_status));
      var metaLine = meta.length
        ? '<div class="rentv-contractors__meta">' + meta.join(' &middot; ') + '</div>'
        : '';
      // per-entry dated attribution — required by TK-10488
      var asOfPerEntry = '<div class="rentv-contractors__asof">' + asOfLine + '</div>';
      return '<li class="rentv-contractors__item">' + bits.join('') + phone + metaLine + asOfPerEntry + '</li>';
    }).join('');

    el.innerHTML =
      '<div class="rentv-contractors__title">Licensed contractors in ' + esc(label) + '</div>' +
      '<ul class="rentv-contractors__list">' + rows + '</ul>' +
      '<div class="rentv-contractors__attr">' +
        esc((data && data.attribution) || 'CA CSLB licensed contractors') +
      '</div>';
    el.hidden = false;
  }

  function render(el, opts) {
    if (!el) return;
    opts = opts || {};
    var endpoint = (opts.endpoint || el.getAttribute('data-endpoint') || '/contrib/contractors')
      .replace(/\/+$/, '');
    var county = opts.county || el.getAttribute('data-county') || '';
    var city = opts.city || el.getAttribute('data-city') || '';
    var limit = opts.limit || el.getAttribute('data-limit') || '';
    var mode = opts.mode || el.getAttribute('data-mode') || 'deal';

    if (!county && !city) { el.hidden = true; return; }

    var qs = new URLSearchParams({ mode: mode });
    if (county) qs.set('county', county);
    if (city) qs.set('city', city);
    if (limit) qs.set('limit', limit);

    fetch(endpoint + '/api/contractors-for-market?' + qs.toString(), {
      headers: { accept: 'application/json' },
      credentials: 'same-origin'
    })
      .then(function (r) { return r.ok ? r.json() : { contractors: [] }; })
      .then(function (data) { renderList(el, data); })
      .catch(function () { el.hidden = true; }); // fail soft — never break the story
  }

  function autoInit(root) {
    var scope = root || document;
    var nodes = scope.querySelectorAll('.rentv-contractors:not([data-rendered])');
    Array.prototype.forEach.call(nodes, function (el) {
      el.setAttribute('data-rendered', '1');
      render(el);
    });
  }

  window.RentvContractors = { render: render, autoInit: autoInit };

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', function () { autoInit(); });
  } else {
    autoInit();
  }
})();
</script>