diff --git a/index.html b/index.html
index 897ee34..6bb39ea 100644
--- a/index.html
+++ b/index.html
@@ -117,6 +117,51 @@
+
@@ -188,6 +233,14 @@
+
Democracy Goes Brrr
@@ -321,22 +384,13 @@ function getMediaURL(path) {
// Initialize application
async function initializeApp() {
- // Update UI elements
updateUIText();
-
- // Load user information
await loadUserData();
-
- // Load verified users data
loadVerifiedUsers();
-
- // Setup event listeners
setupEventListeners();
-
- // Initial load of proposals
fetchProposals();
-
- // Check for proposal in URL
+ fetchLeaderboard();
+ fetchStats();
checkForProposalInUrl();
}
@@ -869,12 +923,18 @@ function initializeShareButtons() {
` : '';
+ const milestoneHtml = getMilestoneBarHtml(proposal.upvotes || 0);
+ const postcodeBadge = proposal.postcode_count > 0
+ ? `across ${proposal.postcode_count} postcode${proposal.postcode_count > 1 ? 's' : ''}`
+ : '';
+
proposalCard.innerHTML = `
${badgeHtml}
${proposal.author_name}
${proposal.text}
${memeHtml}
${petitionInfo}
+ ${milestoneHtml}
${formatDate(proposal.timestamp)}
@@ -1081,6 +1144,13 @@ async function handleVote(proposalId, voteType) {
// Also update the modal if it's open and showing this proposal
updateModalVoteUI(proposalId, voteType, wasUpvoted, wasDownvoted);
+ // Track vote count for email capture prompt
+ const vc = (parseInt(localStorage.getItem('radical_vote_count') || '0')) + 1;
+ localStorage.setItem('radical_vote_count', String(vc));
+ if (vc === 3 && !localStorage.getItem('radical_email_captured')) {
+ setTimeout(showEmailCaptureModal, 600);
+ }
+
// Submit vote to server in the background
try {
await submitVoteToServer(proposalId, voteType, voteType === 'upvote');
@@ -2787,12 +2857,160 @@ async function batchLoadComments() {
function queueCommentLoad(proposalId) {
commentRequestQueue.add(proposalId);
-
if (!commentRequestTimeout) {
commentRequestTimeout = setTimeout(batchLoadComments, COMMENT_REQUEST_DELAY);
}
}
+// ─── STATS BAR ────────────────────────────────────────────────────────────
+async function fetchStats() {
+ try {
+ const data = await safeFetch(`${API_BASE_URL}/stats`);
+ const fmt = n => n >= 1000 ? (n/1000).toFixed(1) + 'k' : String(n);
+ document.getElementById('stat-votes').textContent = fmt(data.votes_today || 0);
+ document.getElementById('stat-users').textContent = fmt(data.total_users || 0);
+ document.getElementById('stat-proposals').textContent = fmt(data.total_proposals || 0);
+ } catch(e) { /* silent */ }
+}
+
+// ─── MILESTONE HELPERS ────────────────────────────────────────────────────
+const MILESTONES = [100, 500, 1000, 5000, 10000];
+const MILESTONE_ACTIONS = {
+ 100: 'Going viral 🔥',
+ 500: 'Community backed 💪',
+ 1000: 'Published publicly 📢',
+ 5000: 'Written to minister 📬',
+ 10000: 'Mass media push 📺'
+};
+
+function getMilestoneInfo(upvotes) {
+ for (let i = 0; i < MILESTONES.length; i++) {
+ if (upvotes < MILESTONES[i]) {
+ const prev = MILESTONES[i - 1] || 0;
+ const pct = Math.round(((upvotes - prev) / (MILESTONES[i] - prev)) * 100);
+ return { next: MILESTONES[i], pct, label: MILESTONE_ACTIONS[MILESTONES[i]], reached: false };
+ }
+ }
+ return { next: null, pct: 100, label: MILESTONE_ACTIONS[10000], reached: true };
+}
+
+function getMilestoneBarHtml(upvotes) {
+ const m = getMilestoneInfo(upvotes);
+ if (m.reached) return ``;
+ return `
+
+
${upvotes}/${m.next} → ${m.label}
+
`;
+}
+
+// ─── LEADERBOARD ──────────────────────────────────────────────────────────
+async function fetchLeaderboard() {
+ try {
+ const data = await safeFetch(`${API_BASE_URL}/leaderboard?userId=${currentUser?.id || ''}`);
+ renderLeaderboard(data.data || []);
+ } catch(e) {
+ document.getElementById('leaderboard-container').innerHTML = 'Could not load leaderboard.
';
+ }
+}
+
+function renderLeaderboard(proposals) {
+ const container = document.getElementById('leaderboard-container');
+ if (!proposals.length) {
+ container.innerHTML = 'No proposals yet. Be first.
';
+ return;
+ }
+ container.innerHTML = proposals.map((p, i) => {
+ const m = getMilestoneInfo(p.upvotes || 0);
+ const postcodeTxt = p.postcode_count > 0 ? `${p.postcode_count} postcodes` : '';
+ return `
+
+ ${i + 1}.
+ ${p.text.substring(0, 120)}${p.text.length > 120 ? '...' : ''}
+
+
+ ▲ ${p.upvotes || 0}
+ ${postcodeTxt}
+ ${formatDate(p.timestamp)}
+
+ ${getMilestoneBarHtml(p.upvotes || 0)}
+
`;
+ }).join('');
+}
+
+function openProposal(proposalId) {
+ fetch(`${API_BASE_URL}/proposals/${proposalId}?userId=${currentUser?.id || 'anonymous'}`)
+ .then(r => r.json())
+ .then(proposal => { if (proposal && proposal.id) createProposalViewModal(proposal); })
+ .catch(() => {});
+}
+
+// ─── EMAIL CAPTURE ────────────────────────────────────────────────────────
+function showEmailCaptureModal() {
+ if (document.querySelector('.email-modal-overlay')) return;
+ const overlay = document.createElement('div');
+ overlay.className = 'email-modal-overlay';
+ overlay.innerHTML = `
+
+
Join the movement
+
Get notified when petitions you support hit milestones and trigger real action.
+
+
+
+
+
`;
+ document.body.appendChild(overlay);
+
+ document.getElementById('em-submit').addEventListener('click', async () => {
+ const email = document.getElementById('em-email').value.trim();
+ const postcode = document.getElementById('em-postcode').value.trim();
+ if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
+ showToastMessage('Please enter a valid email', '#ff0099'); return;
+ }
+ document.getElementById('em-submit').textContent = 'Joining...';
+ document.getElementById('em-submit').disabled = true;
+ try {
+ await safeFetch(`${API_BASE_URL}/email`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ userId: currentUser.id, email, postcode })
+ });
+ localStorage.setItem('radical_email_captured', '1');
+ if (postcode) {
+ currentUser.postcode = postcode;
+ localStorage.setItem('radical_user', JSON.stringify(currentUser));
+ }
+ showToastMessage('✓ You\'re in. Vibe, Vote, Veto.', '#11cc77');
+ document.body.removeChild(overlay);
+ } catch(e) {
+ showToastMessage('Failed to save — try again', '#ff0099');
+ document.getElementById('em-submit').textContent = 'Join Now';
+ document.getElementById('em-submit').disabled = false;
+ }
+ });
+ document.getElementById('em-skip').addEventListener('click', () => {
+ localStorage.setItem('radical_email_captured', 'skipped');
+ document.body.removeChild(overlay);
+ });
+}
+
+// ─── FLAG / REPORT ────────────────────────────────────────────────────────
+async function handleFlag(proposalId, btn) {
+ if (btn.classList.contains('flagged')) return;
+ if (!confirm('Report this proposal as harmful or spam?')) return;
+ try {
+ await safeFetch(`${API_BASE_URL}/flag`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ proposalId, userId: currentUser.id })
+ });
+ btn.classList.add('flagged');
+ btn.title = 'Reported';
+ showToastMessage('Reported. The community will review it.', '#11cc77');
+ } catch(e) {
+ showToastMessage('Could not report — try again', '#ff0099');
+ }
+}
+
hit me
diff --git a/worker.js b/worker.js
index 9150bcd..927bcb3 100644
--- a/worker.js
+++ b/worker.js
@@ -77,6 +77,9 @@ function getCacheControl(path) {
}
export default {
+ async scheduled(event, env, ctx) {
+ ctx.waitUntil(sendWeeklyDigest(env));
+ },
async fetch(request, env, ctx) {
try {
const url = new URL(request.url);
@@ -132,6 +135,14 @@ export default {
return await createOrUpdateCommentVote(request, env);
} else if (path === '/api/comment-votes' && request.method === 'GET') {
return await getCommentVotes(request, env);
+ } else if (path === '/api/leaderboard' && request.method === 'GET') {
+ return await getLeaderboard(request, env);
+ } else if (path === '/api/stats' && request.method === 'GET') {
+ return await getSiteStats(request, env);
+ } else if (path === '/api/email' && request.method === 'POST') {
+ return await saveUserEmail(request, env);
+ } else if (path === '/api/flag' && request.method === 'POST') {
+ return await flagProposal(request, env);
} else if (path === '/api/petition-stats' && request.method === 'GET') {
return await getPetitionStats(request, env);
} else if (path === '/api/petition-svg' && request.method === 'GET') {
@@ -1050,23 +1061,25 @@ async function getProposals(request, env) {
);
const offset = (page - 1) * limit;
const sortBy = url.searchParams.get('sortBy') || 'newest';
-
+ const userId = url.searchParams.get('userId') || '';
+
console.log(`Getting proposals with sorting: ${sortBy}, page: ${page}, limit: ${limit}`);
-
- // Define the base query
+
let query = `
- SELECT
- p.id, p.text, p.timestamp, p.trending, p.meme_url,
+ SELECT
+ p.id, p.text, p.timestamp, p.trending, p.meme_url, p.flag_count,
u.name as author_name, u.id as author_id,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes,
(SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote' AND is_petition = 1) as petition_signatures,
- (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners
+ (SELECT COUNT(DISTINCT pd.user_id) FROM petition_details pd WHERE pd.proposal_id = p.id AND pd.verified = 1) as verified_petitioners,
+ (SELECT vote_type FROM votes WHERE proposal_id = p.id AND user_id = ?) as user_vote,
+ (SELECT COUNT(DISTINCT u2.postcode) FROM votes v2 JOIN users u2 ON v2.user_id = u2.id WHERE v2.proposal_id = p.id AND v2.vote_type = 'upvote' AND u2.postcode IS NOT NULL) as postcode_count
FROM proposals p
JOIN users u ON p.author_id = u.id
+ WHERE (p.hidden = 0 OR p.hidden IS NULL)
`;
-
- // Add sorting
+
if (sortBy === 'newest') {
query += ` ORDER BY p.timestamp DESC`;
} else if (sortBy === 'oldest') {
@@ -1076,17 +1089,19 @@ async function getProposals(request, env) {
} else if (sortBy === 'controversial') {
query += ` ORDER BY (upvotes + downvotes) DESC, ABS(upvotes - downvotes) ASC, p.timestamp DESC`;
} else {
- // Default to newest
query += ` ORDER BY p.timestamp DESC`;
}
-
- // Add pagination
+
query += ` LIMIT ? OFFSET ?`;
-
+
console.log(`Executing query with pagination: limit=${limit}, offset=${offset}`);
-
- // Execute the query
- const proposals = await env.DB.prepare(query).bind(limit, offset).all();
+
+ const proposals = await env.DB.prepare(query).bind(userId, limit, offset).all();
+
+ // Normalise snake_case → camelCase for the frontend
+ if (proposals.results) {
+ proposals.results.forEach(p => { p.userVote = p.user_vote; });
+ }
console.log(`Retrieved ${proposals.results?.length || 0} proposals`);
@@ -1613,9 +1628,13 @@ async function createOrUpdateVote(request, env) {
}
}
- return createResponse({
- proposalId,
- userId,
+ if (voteType === 'upvote') {
+ checkAndNotifyMilestones(proposalId, env).catch(e => console.error('Milestone check error:', e));
+ }
+
+ return createResponse({
+ proposalId,
+ userId,
voteType,
isPetition: voteType === 'upvote' && isPetition,
petitionVerified: voteType === 'upvote' && isPetition
@@ -2257,7 +2276,201 @@ async function serveCustomizedHtml(proposalId, request, env) {
});
} catch (error) {
console.error('Error generating customized HTML:', error);
- // Fall back to regular page if something goes wrong
return await fetch(request);
}
+}
+
+// ─── NEW FEATURES ──────────────────────────────────────────────────────────
+
+const MILESTONES = [100, 500, 1000, 5000, 10000];
+const MILESTONE_ACTIONS = {
+ 100: 'Going viral 🔥',
+ 500: 'Community backed 💪',
+ 1000: 'Published publicly 📢',
+ 5000: 'Written to the relevant minister 📬',
+ 10000: 'Mass media push 📺'
+};
+
+async function getLeaderboard(request, env) {
+ try {
+ const url = new URL(request.url);
+ const userId = url.searchParams.get('userId') || '';
+ const query = `
+ SELECT
+ p.id, p.text, p.timestamp, p.trending, p.meme_url,
+ u.name as author_name,
+ (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes,
+ (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'downvote') as downvotes,
+ (SELECT vote_type FROM votes WHERE proposal_id = p.id AND user_id = ?) as user_vote,
+ (SELECT COUNT(DISTINCT u2.postcode) FROM votes v2 JOIN users u2 ON v2.user_id = u2.id WHERE v2.proposal_id = p.id AND v2.vote_type = 'upvote' AND u2.postcode IS NOT NULL) as postcode_count
+ FROM proposals p
+ JOIN users u ON p.author_id = u.id
+ WHERE (p.hidden = 0 OR p.hidden IS NULL)
+ ORDER BY upvotes DESC
+ LIMIT 10
+ `;
+ const results = await env.DB.prepare(query).bind(userId).all();
+ if (results.results) {
+ results.results.forEach(p => { p.userVote = p.user_vote; });
+ }
+ return createResponse({ data: results.results }, 200, 0);
+ } catch (error) {
+ return createResponse({ error: 'Failed to get leaderboard', details: logError(error, { action: 'get_leaderboard' }) }, 500);
+ }
+}
+
+async function getSiteStats(request, env) {
+ try {
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+ const stats = await env.DB.prepare(`
+ SELECT
+ (SELECT COUNT(*) FROM votes WHERE timestamp >= ?) as votes_today,
+ (SELECT COUNT(*) FROM users) as total_users,
+ (SELECT COUNT(*) FROM proposals WHERE hidden = 0 OR hidden IS NULL) as total_proposals,
+ (SELECT COUNT(*) FROM votes) as total_votes
+ `).bind(today.getTime()).first();
+ return createResponse(stats, 200, 30);
+ } catch (error) {
+ return createResponse({ error: 'Failed to get stats' }, 500);
+ }
+}
+
+async function saveUserEmail(request, env) {
+ try {
+ const data = await request.json();
+ const { userId, email, postcode } = data;
+ if (!userId || !email) return createResponse({ error: 'Missing required fields' }, 400);
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+ if (!emailRegex.test(email)) return createResponse({ error: 'Invalid email format' }, 400);
+ const postcodeClean = postcode ? String(postcode).replace(/\D/g, '').slice(0, 4) : null;
+ await env.DB.prepare(`UPDATE users SET email = ?, postcode = ?, notify_milestones = 1 WHERE id = ?`)
+ .bind(email, postcodeClean || null, userId).run();
+ return createResponse({ success: true }, 200, 0);
+ } catch (error) {
+ return createResponse({ error: 'Failed to save email', details: logError(error, { action: 'save_email' }) }, 500);
+ }
+}
+
+async function flagProposal(request, env) {
+ try {
+ const data = await request.json();
+ const { proposalId, userId } = data;
+ if (!proposalId || !userId) return createResponse({ error: 'Missing required fields' }, 400);
+ const existing = await env.DB.prepare(`SELECT id FROM proposal_flags WHERE proposal_id = ? AND user_id = ?`).bind(proposalId, userId).first();
+ if (existing) return createResponse({ error: 'Already flagged' }, 400);
+ const timestamp = Date.now();
+ await env.DB.prepare(`INSERT INTO proposal_flags (proposal_id, user_id, timestamp) VALUES (?, ?, ?)`).bind(proposalId, userId, timestamp).run();
+ await env.DB.prepare(`UPDATE proposals SET flag_count = COALESCE(flag_count, 0) + 1 WHERE id = ?`).bind(proposalId).run();
+ const proposal = await env.DB.prepare(`SELECT flag_count FROM proposals WHERE id = ?`).bind(proposalId).first();
+ if (proposal && proposal.flag_count >= 5) {
+ await env.DB.prepare(`UPDATE proposals SET hidden = 1 WHERE id = ?`).bind(proposalId).run();
+ }
+ return createResponse({ success: true, proposalId }, 200, 0);
+ } catch (error) {
+ return createResponse({ error: 'Failed to flag proposal', details: logError(error, { action: 'flag_proposal' }) }, 500);
+ }
+}
+
+async function checkAndNotifyMilestones(proposalId, env) {
+ try {
+ const row = await env.DB.prepare(`SELECT COUNT(*) as count FROM votes WHERE proposal_id = ? AND vote_type = 'upvote'`).bind(proposalId).first();
+ const count = row.count;
+ for (const milestone of MILESTONES) {
+ if (count >= milestone) {
+ const existing = await env.DB.prepare(`SELECT id FROM milestone_notifications WHERE proposal_id = ? AND milestone = ?`).bind(proposalId, milestone).first();
+ if (!existing) {
+ await env.DB.prepare(`INSERT INTO milestone_notifications (proposal_id, milestone, sent_at) VALUES (?, ?, ?)`).bind(proposalId, milestone, Date.now()).run();
+ await sendMilestoneEmails(proposalId, milestone, env);
+ }
+ }
+ }
+ } catch (error) {
+ console.error('Milestone check failed:', error);
+ }
+}
+
+async function sendMilestoneEmails(proposalId, milestone, env) {
+ if (!env.RESEND_API_KEY) return;
+ try {
+ const proposal = await env.DB.prepare(`
+ SELECT p.text, u.name as author_name FROM proposals p JOIN users u ON p.author_id = u.id WHERE p.id = ?
+ `).bind(proposalId).first();
+ if (!proposal) return;
+ const subscribers = await env.DB.prepare(`
+ SELECT DISTINCT u.email FROM votes v JOIN users u ON v.user_id = u.id
+ WHERE v.proposal_id = ? AND v.vote_type = 'upvote' AND u.email IS NOT NULL AND u.notify_milestones = 1
+ `).bind(proposalId).all();
+ const action = MILESTONE_ACTIONS[milestone];
+ const proposalUrl = `https://theradicalparty.com/?proposal=${proposalId}`;
+ for (const { email } of subscribers.results) {
+ await fetch('https://api.resend.com/emails', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ from: 'RADICAL ',
+ to: [email],
+ subject: `🔥 ${milestone.toLocaleString()} signatures — this petition is being ${action}`,
+ html: `
+
RADICAL
+
A petition you backed just hit ${milestone.toLocaleString()} signatures!
+
"${proposal.text}"
+
What happens next: ${action}
+
View the petition →
+
+
You're receiving this because you signed this petition on RADICAL. Unsubscribe
+
`
+ })
+ }).catch(e => console.error(`Email to ${email} failed:`, e));
+ }
+ } catch (error) {
+ console.error('sendMilestoneEmails error:', error);
+ }
+}
+
+async function sendWeeklyDigest(env) {
+ if (!env.RESEND_API_KEY) return;
+ try {
+ const weekAgo = Date.now() - 7 * 24 * 60 * 60 * 1000;
+ const top5 = await env.DB.prepare(`
+ SELECT p.id, p.text,
+ (SELECT COUNT(*) FROM votes WHERE proposal_id = p.id AND vote_type = 'upvote') as upvotes
+ FROM proposals p
+ WHERE (p.hidden = 0 OR p.hidden IS NULL)
+ ORDER BY upvotes DESC LIMIT 5
+ `).all();
+ const stats = await env.DB.prepare(`
+ SELECT COUNT(*) as new_votes FROM votes WHERE timestamp >= ?
+ `).bind(weekAgo).first();
+ const subscribers = await env.DB.prepare(`
+ SELECT DISTINCT email FROM users WHERE email IS NOT NULL AND notify_milestones = 1
+ `).all();
+ const proposalRows = top5.results.map((p, i) =>
+ `| ${i + 1} | ${escapeHTML(p.text.substring(0, 120))}${p.text.length > 120 ? '...' : ''} | ${p.upvotes} ▲ |
`
+ ).join('');
+ const html = `
+
RADICAL — Weekly Digest
+
${stats.new_votes} votes cast this week
+
🏆 Top Proposals This Week
+
+
→ Vote now at theradicalparty.com
+
+
You're receiving this weekly digest from RADICAL.
+
`;
+ for (const { email } of subscribers.results) {
+ await fetch('https://api.resend.com/emails', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ from: 'RADICAL ',
+ to: [email],
+ subject: `RADICAL Weekly — ${stats.new_votes} votes this week`,
+ html
+ })
+ }).catch(e => console.error(`Weekly digest to ${email} failed:`, e));
+ }
+ console.log(`Weekly digest sent to ${subscribers.results.length} subscribers`);
+ } catch (error) {
+ console.error('sendWeeklyDigest error:', error);
+ }
}
\ No newline at end of file
diff --git a/wrangler.toml b/wrangler.toml
index c572ee8..c55d2bc 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -45,5 +45,9 @@ zone_name = "theradicalparty.com"
pattern = "theradicalparty.com/styles.css"
zone_name = "theradicalparty.com"
+# Weekly digest — Monday 9am AEST (Sunday 23:00 UTC)
+[triggers]
+crons = ["0 23 * * 0"]
+