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
227 lines
10 KiB
JavaScript
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 →</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 += ' · ' + 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;">✓ 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); });
|
|
}
|
|
})();
|