← 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 { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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) + '’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’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’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’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);
}
})();