← back to Petitionyour

public/js/site.js

312 lines

(function () {
  'use strict';
  var LS_PREFIX = 'petitionyour:';

  // ---- Browse grid: sort + density + search + category (localStorage-persisted) ----
  var grid = document.getElementById('petition-grid');
  if (grid) {
    var sortSelect = document.getElementById('sort-select');
    var densitySlider = document.getElementById('density-slider');
    var searchInput = document.getElementById('q');
    var categorySelect = document.getElementById('category-filter');
    var noResults = document.getElementById('no-results');
    var cards = Array.prototype.slice.call(grid.querySelectorAll('.petition-card'));

    var densityMinWidth = { 1: 420, 2: 340, 3: 280, 4: 220 };

    function applyDensity(val) {
      grid.style.setProperty('--card-min', (densityMinWidth[val] || 280) + 'px');
      grid.dataset.density = val;
      try { localStorage.setItem(LS_PREFIX + 'density', val); } catch (e) {}
    }

    function applySort(mode) {
      var sorted = cards.slice().sort(function (a, b) {
        switch (mode) {
          case 'most-signed':
            return Number(b.dataset.signatures) - Number(a.dataset.signatures);
          case 'title-az':
            return a.dataset.title.localeCompare(b.dataset.title);
          case 'target-az':
            return a.dataset.target.localeCompare(b.dataset.target);
          case 'newest':
          default:
            return new Date(b.dataset.created) - new Date(a.dataset.created);
        }
      });
      sorted.forEach(function (card) { grid.appendChild(card); });
      try { localStorage.setItem(LS_PREFIX + 'sort', mode); } catch (e) {}
    }

    function applyFilter() {
      var q = (searchInput && searchInput.value || '').trim().toLowerCase();
      var cat = (categorySelect && categorySelect.value) || '';
      var visibleCount = 0;
      cards.forEach(function (card) {
        var matchesQ = !q ||
          card.dataset.title.indexOf(q) !== -1 ||
          card.dataset.target.indexOf(q) !== -1 ||
          card.dataset.desc.indexOf(q) !== -1;
        var matchesCat = !cat || card.dataset.category === cat;
        var visible = matchesQ && matchesCat;
        card.hidden = !visible;
        if (visible) visibleCount += 1;
      });
      if (noResults) noResults.hidden = visibleCount !== 0;
    }

    // Restore persisted state
    var savedSort, savedDensity;
    try {
      savedSort = localStorage.getItem(LS_PREFIX + 'sort');
      savedDensity = localStorage.getItem(LS_PREFIX + 'density');
    } catch (e) {}

    if (sortSelect) {
      sortSelect.value = savedSort || 'newest';
      applySort(sortSelect.value);
      sortSelect.addEventListener('change', function () { applySort(sortSelect.value); });
    }
    if (densitySlider) {
      densitySlider.value = savedDensity || '3';
      applyDensity(densitySlider.value);
      densitySlider.addEventListener('input', function () { applyDensity(densitySlider.value); });
    }
    if (searchInput) searchInput.addEventListener('input', applyFilter);
    if (categorySelect) categorySelect.addEventListener('change', applyFilter);
  }

  // ---- Copy link button ----
  var copyBtn = document.getElementById('copy-link-btn');
  if (copyBtn) {
    copyBtn.addEventListener('click', function () {
      var url = copyBtn.dataset.url;
      var done = function () {
        var original = copyBtn.textContent;
        copyBtn.textContent = 'Copied!';
        setTimeout(function () { copyBtn.textContent = original; }, 1800);
      };
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(url).then(done, function () { window.prompt('Copy this link:', url); });
      } else {
        window.prompt('Copy this link:', url);
      }
    });
  }

  // ---- Find & contact your representatives panel ----
  // Resolves ZIP -> actual U.S. Senators (exact) + House rep(s) against the
  // bundled public-domain congress-legislators data, seeds an editable message
  // from the petition, and opens each member's REAL .gov contact form in a new
  // tab with the drafted message copied to the clipboard. We never send
  // anything ourselves — the copy is explicit about that.
  var panel = document.getElementById('reps-panel');
  if (panel) {
    var repsForm = panel.querySelector('#reps-lookup-form');
    var repsZip = panel.querySelector('#reps-zip');
    var repsResult = panel.querySelector('#reps-result');
    var seedTitle = panel.getAttribute('data-seed-title') || '';
    // Hidden input in the sign form that records the offices this signer targeted.
    var signInputId = panel.getAttribute('data-sign-input');
    var signInput = signInputId ? document.getElementById(signInputId) : null;
    // If the sign form has its own ZIP, mirror it into the lookup for convenience.
    var signZip = document.getElementById('zip');

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

    function defaultMessage() {
      var ta = panel.querySelector('#reps-message');
      return ta ? ta.value : '';
    }

    function seededMessage() {
      if (seedTitle) {
        return 'Dear [Representative],\n\n' +
          'As your constituent, I am writing to urge you to support the petition: "' +
          seedTitle + '".\n\n' +
          'This issue matters to me and to many others in our community, and I would ' +
          'appreciate knowing where you stand and what action you will take.\n\n' +
          'Thank you for your service and for representing us.\n\nSincerely,\n[Your name], [Your city, ' +
          'ZIP]';
      }
      return 'Dear [Representative],\n\nAs your constituent, I am writing to share an ' +
        'issue that matters to me and to ask where you stand and what action you will take.\n\n' +
        'Thank you for representing us.\n\nSincerely,\n[Your name], [Your city, ZIP]';
    }

    function copyToClipboard(text, cb) {
      if (navigator.clipboard && navigator.clipboard.writeText) {
        navigator.clipboard.writeText(text).then(function () { cb(true); }, function () { cb(false); });
      } else {
        cb(false);
      }
    }

    function memberCard(m) {
      var chamberLabel = m.chamber === 'senate'
        ? ('U.S. Senator — ' + m.state + (m.stateRank ? ' (' + m.stateRank + ')' : ''))
        : ('U.S. Representative — ' + m.state + (m.district ? '-' + m.district : (m.district === 0 ? ' (at-large)' : '')));
      var contact = m.contactUrl
        ? '<button type="button" class="btn btn-primary btn-sm rep-contact-btn" ' +
            'data-url="' + esc(m.contactUrl) + '" data-name="' + esc(m.name) + '">' +
            'Open ' + esc(m.name) + '&rsquo;s official contact form</button>'
        : '<span class="field-hint">No official web contact form listed — use the phone or the .gov links below.</span>';
      var call = m.phone
        ? '<a class="btn btn-ghost btn-sm" href="tel:' + esc(m.phone.replace(/[^0-9+]/g, '')) + '">Call ' +
            (m.office ? esc(m.office) + ' — ' : '') + esc(m.phone) + '</a>'
        : '';
      return '<div class="rep-card">' +
        '<div class="rep-card-head">' +
        '<strong class="rep-name">' + esc(m.name) + '</strong>' +
        '<span class="rep-party">' + esc(m.party) + '</span>' +
        '</div>' +
        '<div class="rep-meta">' + esc(chamberLabel) + '</div>' +
        (m.office ? '<div class="rep-office">' + esc(m.office) + '</div>' : '') +
        '<div class="rep-actions">' + contact + call + '</div>' +
        '</div>';
    }

    // Which offices do we RECORD as targeted on the signature? Senators (always
    // exact) + the House rep only when we resolved it exactly. In "candidates"
    // mode the House rep is unknown, so we honestly record no House office.
    function recordTargets(d) {
      if (!signInput) return;
      var list = (d.senators || []).slice();
      if (d.house && d.house.mode === 'exact') list = list.concat(d.house.reps || []);
      var compact = list.map(function (m) {
        return { bioguide: m.bioguide, name: m.name, chamber: m.chamber, state: m.state, district: m.district };
      });
      try { signInput.value = JSON.stringify(compact); } catch (e) {}
    }

    function render(d) {
      var html = '';
      html += '<div class="reps-result-block">';
      html += '<p class="reps-state">ZIP <strong>' + esc(d.zip) + '</strong> is in <strong>' +
        esc(d.stateName) + '</strong> (' + esc(d.stateAbbr) + ').</p>';

      // Editable, pre-filled message (seeded once, preserved across re-renders).
      var existing = defaultMessage();
      var msg = existing || seededMessage();
      html += '<label class="reps-msg-label" for="reps-message">Your message (edit freely — this is copied to your clipboard when you open a contact form)</label>';
      html += '<textarea id="reps-message" class="reps-message" rows="7">' + esc(msg) + '</textarea>';
      html += '<p class="honest-note">We do <strong>not</strong> send anything for you. When you click a representative below, ' +
        'we copy this message to your clipboard and open their <em>official government contact form</em> in a new tab — you paste and submit it there.</p>';

      // Senators — always exact.
      html += '<h3 class="reps-group-h">Your U.S. Senators</h3>';
      html += '<div class="rep-cards">' + (d.senators || []).map(memberCard).join('') + '</div>';

      // House — exact vs candidates.
      html += '<h3 class="reps-group-h">Your U.S. House Representative</h3>';
      if (d.house && d.house.mode === 'exact') {
        html += '<div class="rep-cards">' + d.house.reps.map(memberCard).join('') + '</div>';
      } else {
        html += '<div class="alert alert-info reps-ambiguous">' +
          'Your ZIP covers more than one congressional district in ' + esc(d.stateName) +
          ', so we can&rsquo;t pin your exact House member from the ZIP alone. ' +
          'Enter your street address for an exact match (free U.S. Census lookup), ' +
          'or pick from your state&rsquo;s delegation below.' +
          '</div>';
        html += '<form id="reps-addr-form" class="inline-form reps-addr-form">' +
          '<input type="text" id="reps-address" placeholder="123 Main St, City, ' + esc(d.stateAbbr) + '" maxlength="200">' +
          '<button type="submit" class="btn btn-ghost btn-sm">Pin my exact House rep</button>' +
          '</form>';
        html += '<div id="reps-addr-status"></div>';
        html += '<p class="field-hint">Your state&rsquo;s current House delegation (candidates):</p>';
        html += '<div class="rep-cards">' + (d.house.reps || []).map(memberCard).join('') + '</div>';
      }

      // Always-current official backstops.
      html += '<details class="reps-official"><summary>Official .gov lookup tools (always current)</summary><ul class="reps-links">' +
        '<li><a href="' + esc(d.links.findYourHouseRep) + '" target="_blank" rel="noopener noreferrer">House.gov — find your representative by ZIP</a></li>' +
        '<li><a href="' + esc(d.links.senateContactList) + '" target="_blank" rel="noopener noreferrer">Senate.gov — senators contact list</a></li>' +
        '<li><a href="' + esc(d.links.congressMemberSearch) + '" target="_blank" rel="noopener noreferrer">Congress.gov — member search</a></li>' +
        '</ul></details>';

      html += '</div>';
      repsResult.innerHTML = html;
      recordTargets(d);
      wireResultHandlers(d);
    }

    function wireResultHandlers(d) {
      // Contact buttons: copy message, open official form in a new tab.
      var btns = repsResult.querySelectorAll('.rep-contact-btn');
      Array.prototype.forEach.call(btns, function (btn) {
        btn.addEventListener('click', function () {
          var url = btn.getAttribute('data-url');
          var msg = defaultMessage();
          copyToClipboard(msg, function (ok) {
            var original = btn.innerHTML;
            btn.innerHTML = ok ? 'Message copied — opening form…' : 'Opening form (copy failed — paste manually)';
            window.open(url, '_blank', 'noopener');
            setTimeout(function () { btn.innerHTML = original; }, 2200);
          });
        });
      });
      // Address -> exact district -> re-resolve.
      var addrForm = repsResult.querySelector('#reps-addr-form');
      if (addrForm) {
        addrForm.addEventListener('submit', function (evt) {
          evt.preventDefault();
          var address = (repsResult.querySelector('#reps-address').value || '').trim();
          var status = repsResult.querySelector('#reps-addr-status');
          if (!address) return;
          status.innerHTML = '<p class="field-hint">Looking up your district…</p>';
          fetch('/api/geocode-district?address=' + encodeURIComponent(address))
            .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
            .then(function (res) {
              if (!res.ok || !res.j.ok || res.j.district == null) {
                status.innerHTML = '<div class="alert alert-error">' + esc((res.j && res.j.error) || 'Could not find that address.') + '</div>';
                return;
              }
              // Re-resolve with the pinned district (preserve current message).
              lookup(d.zip, res.j.district);
            })
            .catch(function () {
              status.innerHTML = '<div class="alert alert-error">Address lookup failed — please try again.</div>';
            });
        });
      }
    }

    function lookup(zip, district) {
      zip = (zip || '').trim();
      if (!zip) return;
      repsResult.setAttribute('aria-busy', 'true');
      if (!repsResult.innerHTML) repsResult.innerHTML = '<p class="field-hint">Looking up your representatives…</p>';
      var qs = '/api/reps?zip=' + encodeURIComponent(zip) + (district != null ? '&district=' + encodeURIComponent(district) : '');
      fetch(qs)
        .then(function (r) { return r.json().then(function (j) { return { ok: r.ok, j: j }; }); })
        .then(function (res) {
          repsResult.removeAttribute('aria-busy');
          if (!res.ok || !res.j.ok) {
            repsResult.innerHTML = '<div class="alert alert-error">' + esc((res.j && res.j.error) || 'Could not resolve that ZIP.') + '</div>';
            return;
          }
          render(res.j);
        })
        .catch(function () {
          repsResult.removeAttribute('aria-busy');
          repsResult.innerHTML = '<div class="alert alert-error">Lookup failed — please try again.</div>';
        });
    }

    repsForm.addEventListener('submit', function (evt) {
      evt.preventDefault();
      lookup(repsZip.value);
    });

    // Convenience: seed the panel ZIP from the sign-form ZIP if present.
    if (signZip && signZip.value && !repsZip.value) repsZip.value = signZip.value;

    // Auto-run if a ZIP was pre-filled (e.g. arriving with ?zip= or from sign form).
    if (repsZip.value) lookup(repsZip.value);
  }
})();