changemaker.lite/mkdocs/docs/assets/js/straw-poll-widget.js
bunker-admin 902adce646 Add Straw Polls feature: quick opinion polling with public landers, MkDocs widgets, and social integration
Full-stack implementation across 7 sprints:
- Backend: 5 Prisma models (StrawPoll, Option, Vote, Comment, Challenge), 4 enums, POLLS_ADMIN role,
  admin CRUD routes, public voting/SSE/widget endpoints, BullMQ auto-close queue, rate limiting
- Admin: StrawPollsPage with inline drawers (campaigns pattern), PollResults bar chart, sidebar under Advocacy
- Public: dedicated poll lander with real-time SSE updates, browse page, anonymous voting with token dedup
- MkDocs: straw-poll-widget.js hydration (inline vote + card link modes), GrapesJS block types
- Social: feed activity (poll_voted), friend badge integration, challenge notifications, notification preferences
- Feature flag: enablePolls toggle in Settings, FeatureGate, Zod schema

Bunker Admin
2026-03-31 10:16:56 -06:00

227 lines
10 KiB
JavaScript

/**
* Straw Poll Widget Hydration for MkDocs
*
* Supports two modes:
* .straw-poll-inline — Full voting UI embedded in docs page
* .straw-poll-card — Preview card linking to the full poll lander
*
* Uses the lightweight /api/straw-polls/widget/:slug endpoint (cached).
* Follows the scheduling-poll.js hydration pattern.
*/
(function () {
'use strict';
function getApiUrl() {
if (window.PAYMENT_API_URL) return window.PAYMENT_API_URL;
if (window.API_URL) return window.API_URL;
var host = window.location.hostname;
if (host !== 'localhost' && host.indexOf('.') !== -1) {
var parts = host.split('.');
var base = parts.slice(-2).join('.');
return window.location.protocol + '//api.' + base;
}
return 'http://localhost:4000';
}
function getAppUrl() {
if (window.APP_URL) return window.APP_URL;
var host = window.location.hostname;
if (host !== 'localhost' && host.indexOf('.') !== -1) {
var parts = host.split('.');
var base = parts.slice(-2).join('.');
return window.location.protocol + '//app.' + base;
}
return 'http://localhost:3000';
}
var COLORS = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#13c2c2', '#eb2f96'];
var YNA_COLORS = { Yes: '#52c41a', No: '#ff4d4f', Abstain: '#8c8c8c' };
function tokenKey(slug) {
return 'straw_poll_voter_token_' + slug;
}
// ===== Card Mode =====
function renderCard(block, poll, appUrl) {
var html = '<div style="border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:16px; max-width:400px; margin:12px auto;">';
html += '<div style="font-size:11px; text-transform:uppercase; opacity:0.5; margin-bottom:6px;">';
html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice') + ' Poll</div>';
html += '<h3 style="margin:0 0 8px; font-size:1.1rem;">' + poll.title + '</h3>';
html += '<div style="font-size:13px; opacity:0.65; margin-bottom:12px;">' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '</div>';
if (poll.status === 'ACTIVE') {
html += '<a href="' + appUrl + '/straw-poll/' + encodeURIComponent(poll.slug) + '" target="_blank" rel="noopener noreferrer" ';
html += 'style="display:inline-block; padding:10px 24px; background:#1890ff; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
html += 'Vote Now &rarr;</a>';
} else {
html += '<span style="padding:3px 10px; border-radius:4px; font-size:12px; background:rgba(250,140,22,0.15); color:#fa8c16;">Closed</span>';
}
html += '</div>';
block.innerHTML = html;
}
// ===== Inline Mode =====
function renderInline(block, poll, apiUrl) {
var slug = poll.slug;
var storedToken = localStorage.getItem(tokenKey(slug));
var hasVoted = !!storedToken;
var showResults = poll.totalVotes > 0;
var html = '<div style="max-width:500px; margin:12px auto; border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:20px;">';
// Title
html += '<h3 style="margin:0 0 4px; font-size:1.1rem;">' + poll.title + '</h3>';
html += '<div style="font-size:11px; opacity:0.5; margin-bottom:14px;">';
html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice');
html += ' &middot; ' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '</div>';
if (poll.status === 'ACTIVE' && !hasVoted) {
// Vote form
html += '<div id="sp-vote-form-' + slug + '">';
poll.options.forEach(function (opt, i) {
var isYNA = poll.type === 'YES_NO_ABSTAIN';
var color = isYNA ? (YNA_COLORS[opt.label] || COLORS[i]) : COLORS[i % COLORS.length];
html += '<button class="sp-opt-btn" data-option-id="' + opt.id + '" style="display:block; width:100%; padding:10px 14px; margin-bottom:6px; ';
html += 'border:2px solid ' + color + '44; border-radius:6px; background:transparent; color:inherit; cursor:pointer; text-align:left; font-size:14px; transition:all 0.2s;" ';
html += 'onmouseover="this.style.background=\'' + color + '22\'" onmouseout="this.style.background=\'transparent\'">';
html += opt.label + '</button>';
});
html += '<input type="text" id="sp-voter-name-' + slug + '" placeholder="Your name (optional)" style="width:100%; padding:8px 12px; margin:8px 0; border:1px solid rgba(255,255,255,0.2); border-radius:4px; background:transparent; color:inherit; font-size:13px;" />';
html += '<button id="sp-submit-' + slug + '" disabled style="display:block; width:100%; padding:10px; border:none; border-radius:6px; background:#1890ff; color:#fff; font-weight:600; font-size:14px; cursor:pointer; opacity:0.5;">';
html += 'Submit Vote</button>';
html += '</div>';
}
// Results
if (showResults && (hasVoted || poll.status !== 'ACTIVE')) {
html += renderResultsHtml(poll);
}
if (hasVoted && poll.status === 'ACTIVE') {
html += '<div style="text-align:center; padding:8px; margin-top:8px; font-size:13px; color:#52c41a;">&#10003; You\'ve voted</div>';
}
html += '</div>';
block.innerHTML = html;
// Wire up vote form
if (poll.status === 'ACTIVE' && !hasVoted) {
wireVoteForm(block, poll, apiUrl, slug);
}
}
function renderResultsHtml(poll) {
var html = '<div style="margin-top:12px;">';
poll.options.forEach(function (opt, i) {
var pct = poll.totalVotes > 0 ? Math.round((opt.voteCount / poll.totalVotes) * 100) : 0;
var color = poll.type === 'YES_NO_ABSTAIN' ? (YNA_COLORS[opt.label] || COLORS[i]) : COLORS[i % COLORS.length];
html += '<div style="margin-bottom:8px;">';
html += '<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:2px;">';
html += '<span>' + opt.label + '</span><span style="opacity:0.65;">' + opt.voteCount + ' (' + pct + '%)</span></div>';
html += '<div style="height:6px; background:rgba(255,255,255,0.1); border-radius:3px; overflow:hidden;">';
html += '<div style="height:100%; width:' + pct + '%; background:' + color + '; border-radius:3px; transition:width 0.3s;"></div>';
html += '</div></div>';
});
html += '</div>';
return html;
}
function wireVoteForm(block, poll, apiUrl, slug) {
var selectedId = null;
var buttons = block.querySelectorAll('.sp-opt-btn');
var submitBtn = block.querySelector('#sp-submit-' + slug);
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
selectedId = btn.getAttribute('data-option-id');
buttons.forEach(function (b) { b.style.borderWidth = '2px'; b.style.fontWeight = 'normal'; });
btn.style.borderWidth = '3px';
btn.style.fontWeight = '600';
submitBtn.disabled = false;
submitBtn.style.opacity = '1';
});
});
submitBtn.addEventListener('click', function () {
if (!selectedId) return;
submitBtn.disabled = true;
submitBtn.textContent = 'Submitting...';
var voterName = (block.querySelector('#sp-voter-name-' + slug) || {}).value || '';
var body = { optionId: selectedId };
if (voterName) body.voterName = voterName;
var storedToken = localStorage.getItem(tokenKey(slug));
if (storedToken) body.voterToken = storedToken;
fetch(apiUrl + '/api/straw-polls/public/' + encodeURIComponent(slug) + '/vote', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
.then(function (res) { return res.json(); })
.then(function (data) {
if (data.voterToken) localStorage.setItem(tokenKey(slug), data.voterToken);
// Re-fetch and re-render with results
return fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug)).then(function (r) { return r.json(); });
})
.then(function (updated) {
renderInline(block, updated, apiUrl);
})
.catch(function () {
submitBtn.textContent = 'Error — try again';
submitBtn.disabled = false;
submitBtn.style.opacity = '1';
});
});
}
// ===== Hydration =====
function hydrateBlocks() {
var apiUrl = getApiUrl();
var appUrl = getAppUrl();
// Inline embeds
document.querySelectorAll('.straw-poll-inline').forEach(function (block) {
if (block.getAttribute('data-hydrated') === 'true') return;
var slug = block.getAttribute('data-poll-slug');
if (!slug) return;
block.setAttribute('data-hydrated', 'true');
block.innerHTML = '<div style="text-align:center; padding:16px; opacity:0.5;">Loading poll...</div>';
fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug))
.then(function (res) { if (!res.ok) throw new Error(); return res.json(); })
.then(function (poll) { renderInline(block, poll, apiUrl); })
.catch(function () {
block.innerHTML = '<div style="text-align:center; padding:16px; opacity:0.5;">Poll unavailable</div>';
});
});
// Card links
document.querySelectorAll('.straw-poll-card').forEach(function (block) {
if (block.getAttribute('data-hydrated') === 'true') return;
var slug = block.getAttribute('data-poll-slug');
if (!slug) return;
block.setAttribute('data-hydrated', 'true');
block.innerHTML = '<div style="text-align:center; padding:16px; opacity:0.5;">Loading...</div>';
fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug))
.then(function (res) { if (!res.ok) throw new Error(); return res.json(); })
.then(function (poll) { renderCard(block, poll, appUrl); })
.catch(function () {
block.innerHTML = '<div style="text-align:center; padding:16px; opacity:0.5;">Poll unavailable</div>';
});
});
}
// Initial hydration
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrateBlocks);
} else {
hydrateBlocks();
}
// Re-hydrate on MkDocs SPA navigation
if (typeof document$ !== 'undefined') {
document$.subscribe(function () { setTimeout(hydrateBlocks, 100); });
}
})();