- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage. - Created a logger utility using Winston for structured logging with daily rotation and various log levels. - Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity. - Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support. - Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
997 lines
36 KiB
JavaScript
997 lines
36 KiB
JavaScript
// Response Wall JavaScript
|
|
|
|
let currentCampaignSlug = null;
|
|
let currentCampaign = null;
|
|
let currentOffset = 0;
|
|
let currentSort = 'recent';
|
|
let currentLevel = '';
|
|
const LIMIT = 20;
|
|
let loadedRepresentatives = [];
|
|
|
|
// Initialize
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
console.log('Response Wall: Initializing...');
|
|
|
|
// Get campaign slug from URL if present
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
currentCampaignSlug = urlParams.get('campaign');
|
|
|
|
console.log('Campaign slug:', currentCampaignSlug);
|
|
|
|
if (!currentCampaignSlug) {
|
|
showError('No campaign specified');
|
|
return;
|
|
}
|
|
|
|
// Load initial data
|
|
loadResponseStats();
|
|
loadResponses(true);
|
|
|
|
// Set up event listeners
|
|
document.getElementById('sort-select').addEventListener('change', (e) => {
|
|
currentSort = e.target.value;
|
|
loadResponses(true);
|
|
});
|
|
|
|
document.getElementById('level-filter').addEventListener('change', (e) => {
|
|
currentLevel = e.target.value;
|
|
loadResponses(true);
|
|
});
|
|
|
|
const submitBtn = document.getElementById('submit-response-btn');
|
|
console.log('Submit button found:', !!submitBtn);
|
|
if (submitBtn) {
|
|
submitBtn.addEventListener('click', () => {
|
|
console.log('Submit button clicked');
|
|
openSubmitModal();
|
|
});
|
|
}
|
|
|
|
// Use event delegation for empty state button since it's dynamically shown
|
|
document.addEventListener('click', (e) => {
|
|
if (e.target.id === 'empty-state-submit-btn') {
|
|
console.log('Empty state button clicked');
|
|
openSubmitModal();
|
|
}
|
|
});
|
|
|
|
const modalCloseBtn = document.getElementById('modal-close-btn');
|
|
if (modalCloseBtn) {
|
|
modalCloseBtn.addEventListener('click', closeSubmitModal);
|
|
}
|
|
|
|
const cancelBtn = document.getElementById('cancel-submit-btn');
|
|
if (cancelBtn) {
|
|
cancelBtn.addEventListener('click', closeSubmitModal);
|
|
}
|
|
|
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
|
if (loadMoreBtn) {
|
|
loadMoreBtn.addEventListener('click', loadMoreResponses);
|
|
}
|
|
|
|
const form = document.getElementById('submit-response-form');
|
|
if (form) {
|
|
form.addEventListener('submit', handleSubmitResponse);
|
|
}
|
|
|
|
// Postal code lookup button
|
|
const lookupBtn = document.getElementById('lookup-rep-btn');
|
|
if (lookupBtn) {
|
|
lookupBtn.addEventListener('click', handlePostalLookup);
|
|
}
|
|
|
|
// Postal code input formatting
|
|
const postalInput = document.getElementById('modal-postal-code');
|
|
if (postalInput) {
|
|
postalInput.addEventListener('input', formatPostalCodeInput);
|
|
postalInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handlePostalLookup();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Representative selection
|
|
const repSelect = document.getElementById('rep-select');
|
|
if (repSelect) {
|
|
repSelect.addEventListener('change', handleRepresentativeSelect);
|
|
}
|
|
|
|
console.log('Response Wall: Initialization complete');
|
|
});
|
|
|
|
// Postal Code Lookup Functions
|
|
function formatPostalCodeInput(e) {
|
|
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
|
|
|
// Format as A1A 1A1
|
|
if (value.length > 3) {
|
|
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
|
|
}
|
|
|
|
e.target.value = value;
|
|
}
|
|
|
|
function validatePostalCode(postalCode) {
|
|
const cleaned = postalCode.replace(/\s/g, '');
|
|
|
|
// Check format: Letter-Number-Letter Number-Letter-Number
|
|
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
|
|
if (!regex.test(cleaned)) {
|
|
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
|
|
}
|
|
|
|
// Check if it's an Alberta postal code (starts with T)
|
|
if (!cleaned.startsWith('T')) {
|
|
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
|
|
}
|
|
|
|
return { valid: true };
|
|
}
|
|
|
|
async function handlePostalLookup() {
|
|
const postalInput = document.getElementById('modal-postal-code');
|
|
const postalCode = postalInput.value.trim();
|
|
|
|
if (!postalCode) {
|
|
showError('Please enter a postal code');
|
|
return;
|
|
}
|
|
|
|
const validation = validatePostalCode(postalCode);
|
|
if (!validation.valid) {
|
|
showError(validation.message);
|
|
return;
|
|
}
|
|
|
|
const lookupBtn = document.getElementById('lookup-rep-btn');
|
|
lookupBtn.disabled = true;
|
|
lookupBtn.textContent = '🔄 Searching...';
|
|
|
|
try {
|
|
const response = await window.apiClient.getRepresentativesByPostalCode(postalCode);
|
|
const data = response.data || response;
|
|
|
|
loadedRepresentatives = data.representatives || [];
|
|
|
|
if (loadedRepresentatives.length === 0) {
|
|
showError('No representatives found for this postal code');
|
|
document.getElementById('rep-select-group').style.display = 'none';
|
|
} else {
|
|
displayRepresentativeOptions(loadedRepresentatives);
|
|
showSuccess(`Found ${loadedRepresentatives.length} representatives`);
|
|
}
|
|
} catch (error) {
|
|
console.error('Postal lookup failed:', error);
|
|
showError('Failed to lookup representatives: ' + error.message);
|
|
} finally {
|
|
lookupBtn.disabled = false;
|
|
lookupBtn.textContent = '🔍 Search';
|
|
}
|
|
}
|
|
|
|
function displayRepresentativeOptions(representatives) {
|
|
const repSelect = document.getElementById('rep-select');
|
|
const repSelectGroup = document.getElementById('rep-select-group');
|
|
|
|
// Clear existing options
|
|
repSelect.innerHTML = '';
|
|
|
|
// Add representatives as options
|
|
representatives.forEach((rep, index) => {
|
|
const option = document.createElement('option');
|
|
option.value = index;
|
|
|
|
// Format display text
|
|
let displayText = rep.name;
|
|
if (rep.district_name) {
|
|
displayText += ` - ${rep.district_name}`;
|
|
}
|
|
if (rep.party_name) {
|
|
displayText += ` (${rep.party_name})`;
|
|
}
|
|
displayText += ` [${rep.elected_office || 'Representative'}]`;
|
|
|
|
option.textContent = displayText;
|
|
repSelect.appendChild(option);
|
|
});
|
|
|
|
// Show the select group
|
|
repSelectGroup.style.display = 'block';
|
|
}
|
|
|
|
function handleRepresentativeSelect(e) {
|
|
const selectedIndex = e.target.value;
|
|
if (selectedIndex === '') return;
|
|
|
|
const rep = loadedRepresentatives[selectedIndex];
|
|
if (!rep) return;
|
|
|
|
// Auto-fill form fields
|
|
document.getElementById('representative-name').value = rep.name || '';
|
|
document.getElementById('representative-title').value = rep.elected_office || '';
|
|
|
|
// Set government level based on elected office
|
|
const level = determineGovernmentLevel(rep.elected_office);
|
|
document.getElementById('representative-level').value = level;
|
|
|
|
// Store email for verification option
|
|
if (rep.email) {
|
|
// Handle email being either string or array
|
|
const emailValue = Array.isArray(rep.email) ? rep.email[0] : rep.email;
|
|
document.getElementById('representative-email').value = emailValue;
|
|
// Enable verification checkbox if we have an email
|
|
const verificationCheckbox = document.getElementById('send-verification');
|
|
verificationCheckbox.disabled = false;
|
|
} else {
|
|
document.getElementById('representative-email').value = '';
|
|
// Disable verification checkbox if no email
|
|
const verificationCheckbox = document.getElementById('send-verification');
|
|
verificationCheckbox.disabled = true;
|
|
verificationCheckbox.checked = false;
|
|
}
|
|
|
|
showSuccess('Representative details filled. Please complete the rest of the form.');
|
|
}
|
|
|
|
function determineGovernmentLevel(electedOffice) {
|
|
if (!electedOffice) return '';
|
|
|
|
const office = electedOffice.toLowerCase();
|
|
|
|
if (office.includes('mp') || office.includes('member of parliament')) {
|
|
return 'Federal';
|
|
} else if (office.includes('mla') || office.includes('member of the legislative assembly')) {
|
|
return 'Provincial';
|
|
} else if (office.includes('councillor') || office.includes('councilor') || office.includes('mayor')) {
|
|
return 'Municipal';
|
|
} else if (office.includes('trustee') || office.includes('school board')) {
|
|
return 'School Board';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
// Load response statistics and campaign details
|
|
async function loadResponseStats() {
|
|
try {
|
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// Store campaign data
|
|
currentCampaign = data.campaign;
|
|
|
|
// Update stats
|
|
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
|
|
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
|
|
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
|
|
document.getElementById('stats-banner').style.display = 'flex';
|
|
|
|
// Render campaign header with campaign info
|
|
renderCampaignHeader();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading stats:', error);
|
|
}
|
|
}
|
|
|
|
// Render campaign header with background image and navigation
|
|
function renderCampaignHeader() {
|
|
console.log('renderCampaignHeader called with campaign:', currentCampaign);
|
|
|
|
if (!currentCampaign) {
|
|
console.warn('No campaign data available');
|
|
return;
|
|
}
|
|
|
|
const headerElement = document.querySelector('.response-wall-header');
|
|
if (!headerElement) {
|
|
console.warn('Header element not found');
|
|
return;
|
|
}
|
|
|
|
// Update campaign name subtitle
|
|
const campaignNameElement = document.getElementById('campaign-name');
|
|
const descriptionElement = document.getElementById('campaign-description');
|
|
|
|
console.log('Campaign name element:', campaignNameElement);
|
|
console.log('Campaign title:', currentCampaign.title);
|
|
|
|
if (campaignNameElement && currentCampaign.title) {
|
|
campaignNameElement.textContent = currentCampaign.title;
|
|
campaignNameElement.style.display = 'block';
|
|
console.log('Campaign name set to:', currentCampaign.title);
|
|
}
|
|
|
|
if (descriptionElement) {
|
|
descriptionElement.textContent = currentCampaign.description || 'See what representatives are saying back to constituents';
|
|
}
|
|
|
|
// Add cover photo if available
|
|
if (currentCampaign.cover_photo) {
|
|
headerElement.classList.add('has-cover');
|
|
headerElement.style.backgroundImage = `url(/uploads/${currentCampaign.cover_photo})`;
|
|
} else {
|
|
headerElement.classList.remove('has-cover');
|
|
headerElement.style.backgroundImage = '';
|
|
}
|
|
|
|
// Set up navigation button listeners
|
|
const campaignBtn = document.getElementById('nav-to-campaign');
|
|
const homeBtn = document.getElementById('nav-to-home');
|
|
|
|
if (campaignBtn) {
|
|
campaignBtn.addEventListener('click', () => {
|
|
window.location.href = `/campaign/${currentCampaign.slug}`;
|
|
});
|
|
}
|
|
|
|
if (homeBtn) {
|
|
homeBtn.addEventListener('click', () => {
|
|
window.location.href = '/';
|
|
});
|
|
}
|
|
|
|
// Set up social share buttons
|
|
setupShareButtons();
|
|
}
|
|
|
|
// Setup social share buttons
|
|
function setupShareButtons() {
|
|
const shareUrl = window.location.href;
|
|
|
|
// Social menu toggle
|
|
const socialsToggle = document.getElementById('share-socials-toggle');
|
|
const socialsMenu = document.getElementById('share-socials-menu');
|
|
|
|
if (socialsToggle && socialsMenu) {
|
|
socialsToggle.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
socialsMenu.classList.toggle('show');
|
|
socialsToggle.classList.toggle('active');
|
|
});
|
|
|
|
// Close menu when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.share-socials-container')) {
|
|
socialsMenu.classList.remove('show');
|
|
socialsToggle.classList.remove('active');
|
|
}
|
|
});
|
|
}
|
|
|
|
// Facebook share
|
|
document.getElementById('share-facebook')?.addEventListener('click', () => {
|
|
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// Twitter share
|
|
document.getElementById('share-twitter')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// LinkedIn share
|
|
document.getElementById('share-linkedin')?.addEventListener('click', () => {
|
|
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// WhatsApp share
|
|
document.getElementById('share-whatsapp')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
|
window.open(url, '_blank');
|
|
});
|
|
|
|
// Bluesky share
|
|
document.getElementById('share-bluesky')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
|
|
document.getElementById('share-instagram')?.addEventListener('click', () => {
|
|
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
|
|
// Automatically copy the link
|
|
navigator.clipboard.writeText(shareUrl).catch(() => {
|
|
console.log('Failed to copy link automatically');
|
|
});
|
|
});
|
|
|
|
// Reddit share
|
|
document.getElementById('share-reddit')?.addEventListener('click', () => {
|
|
const title = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
|
|
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
|
|
window.open(url, '_blank', 'width=800,height=600');
|
|
});
|
|
|
|
// Threads share
|
|
document.getElementById('share-threads')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// Telegram share
|
|
document.getElementById('share-telegram')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// Mastodon share
|
|
document.getElementById('share-mastodon')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
// Mastodon requires instance selection - opens a composer with text
|
|
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
|
|
if (instance) {
|
|
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
}
|
|
});
|
|
|
|
// SMS share
|
|
document.getElementById('share-sms')?.addEventListener('click', () => {
|
|
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
|
|
const body = text + ' ' + shareUrl;
|
|
// Use Web Share API if available, otherwise fallback to SMS protocol
|
|
if (navigator.share) {
|
|
navigator.share({
|
|
title: currentCampaign ? currentCampaign.title : 'Response Wall',
|
|
text: body
|
|
}).catch(() => {
|
|
// Fallback to SMS protocol
|
|
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
|
});
|
|
} else {
|
|
// SMS protocol (works on mobile)
|
|
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
|
}
|
|
});
|
|
|
|
// Slack share
|
|
document.getElementById('share-slack')?.addEventListener('click', () => {
|
|
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
|
|
window.open(url, '_blank', 'width=600,height=400');
|
|
});
|
|
|
|
// Discord share
|
|
document.getElementById('share-discord')?.addEventListener('click', () => {
|
|
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
|
|
// Automatically copy the link
|
|
navigator.clipboard.writeText(shareUrl).catch(() => {
|
|
console.log('Failed to copy link automatically');
|
|
});
|
|
});
|
|
|
|
// Print/PDF share
|
|
document.getElementById('share-print')?.addEventListener('click', () => {
|
|
window.print();
|
|
});
|
|
|
|
// Email share
|
|
document.getElementById('share-email')?.addEventListener('click', () => {
|
|
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
|
|
const body = currentCampaign ?
|
|
`I thought you might be interested in seeing what representatives are saying about this campaign:\n\n${currentCampaign.title}\n\n${shareUrl}` :
|
|
`Check out these representative responses:\n\n${shareUrl}`;
|
|
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
});
|
|
|
|
// Copy link
|
|
document.getElementById('share-copy')?.addEventListener('click', async () => {
|
|
const copyBtn = document.getElementById('share-copy');
|
|
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl);
|
|
copyBtn.classList.add('copied');
|
|
copyBtn.title = 'Copied!';
|
|
|
|
setTimeout(() => {
|
|
copyBtn.classList.remove('copied');
|
|
copyBtn.title = 'Copy Link';
|
|
}, 2000);
|
|
} catch (err) {
|
|
// Fallback for older browsers
|
|
const textArea = document.createElement('textarea');
|
|
textArea.value = shareUrl;
|
|
textArea.style.position = 'fixed';
|
|
textArea.style.left = '-999999px';
|
|
document.body.appendChild(textArea);
|
|
textArea.select();
|
|
try {
|
|
document.execCommand('copy');
|
|
copyBtn.classList.add('copied');
|
|
copyBtn.title = 'Copied!';
|
|
setTimeout(() => {
|
|
copyBtn.classList.remove('copied');
|
|
copyBtn.title = 'Copy Link';
|
|
}, 2000);
|
|
} catch (err) {
|
|
console.error('Failed to copy:', err);
|
|
alert('Failed to copy link. Please copy manually: ' + shareUrl);
|
|
}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
});
|
|
|
|
// QR code share
|
|
document.getElementById('share-qrcode')?.addEventListener('click', () => {
|
|
openQRCodeModal();
|
|
});
|
|
}
|
|
|
|
function openQRCodeModal() {
|
|
const modal = document.getElementById('qrcode-modal');
|
|
const qrcodeImage = document.getElementById('qrcode-image');
|
|
const closeBtn = modal.querySelector('.qrcode-close');
|
|
const downloadBtn = document.getElementById('download-qrcode-btn');
|
|
|
|
// Build QR code URL for response wall
|
|
const qrcodeUrl = `/api/campaigns/${currentCampaignSlug}/qrcode?type=response-wall`;
|
|
qrcodeImage.src = qrcodeUrl;
|
|
|
|
// Show modal
|
|
modal.classList.add('show');
|
|
|
|
// Close button handler
|
|
const closeModal = () => {
|
|
modal.classList.remove('show');
|
|
};
|
|
|
|
closeBtn.onclick = closeModal;
|
|
|
|
// Close when clicking outside the modal content
|
|
modal.onclick = (event) => {
|
|
if (event.target === modal) {
|
|
closeModal();
|
|
}
|
|
};
|
|
|
|
// Download button handler
|
|
downloadBtn.onclick = async () => {
|
|
try {
|
|
const response = await fetch(qrcodeUrl);
|
|
const blob = await response.blob();
|
|
const url = window.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `${currentCampaignSlug}-response-wall-qrcode.png`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
window.URL.revokeObjectURL(url);
|
|
} catch (error) {
|
|
console.error('Failed to download QR code:', error);
|
|
alert('Failed to download QR code. Please try again.');
|
|
}
|
|
};
|
|
|
|
// Close on Escape key
|
|
const handleEscape = (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeModal();
|
|
document.removeEventListener('keydown', handleEscape);
|
|
}
|
|
};
|
|
document.addEventListener('keydown', handleEscape);
|
|
}
|
|
|
|
// Load responses
|
|
async function loadResponses(reset = false) {
|
|
if (reset) {
|
|
currentOffset = 0;
|
|
document.getElementById('responses-container').innerHTML = '';
|
|
}
|
|
|
|
showLoading(true);
|
|
|
|
try {
|
|
const params = new URLSearchParams({
|
|
sort: currentSort,
|
|
level: currentLevel,
|
|
offset: currentOffset,
|
|
limit: LIMIT
|
|
});
|
|
|
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
|
|
const data = await response.json();
|
|
|
|
showLoading(false);
|
|
|
|
if (data.success && data.responses.length > 0) {
|
|
renderResponses(data.responses);
|
|
|
|
// Show/hide load more button
|
|
if (data.pagination.hasMore) {
|
|
document.getElementById('load-more-container').style.display = 'block';
|
|
} else {
|
|
document.getElementById('load-more-container').style.display = 'none';
|
|
}
|
|
} else if (reset) {
|
|
showEmptyState();
|
|
}
|
|
} catch (error) {
|
|
showLoading(false);
|
|
showError('Failed to load responses');
|
|
console.error('Error loading responses:', error);
|
|
}
|
|
}
|
|
|
|
// Render responses
|
|
function renderResponses(responses) {
|
|
const container = document.getElementById('responses-container');
|
|
|
|
responses.forEach(response => {
|
|
const card = createResponseCard(response);
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
// Create response card element
|
|
function createResponseCard(response) {
|
|
const card = document.createElement('div');
|
|
card.className = 'response-card';
|
|
card.dataset.responseId = response.id;
|
|
|
|
const createdDate = new Date(response.created_at).toLocaleDateString('en-US', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric'
|
|
});
|
|
|
|
let badges = `<span class="badge badge-level">${escapeHtml(response.representative_level)}</span>`;
|
|
badges += `<span class="badge badge-type">${escapeHtml(response.response_type)}</span>`;
|
|
if (response.is_verified) {
|
|
badges = `<span class="badge badge-verified">✓ Verified</span>` + badges;
|
|
}
|
|
|
|
let submittedBy = 'Anonymous';
|
|
if (!response.is_anonymous && response.submitted_by_name) {
|
|
submittedBy = escapeHtml(response.submitted_by_name);
|
|
}
|
|
|
|
let userCommentHtml = '';
|
|
if (response.user_comment) {
|
|
userCommentHtml = `
|
|
<div class="user-comment">
|
|
<span class="user-comment-label">Constituent's Comment:</span>
|
|
${escapeHtml(response.user_comment)}
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
let screenshotHtml = '';
|
|
if (response.screenshot_url) {
|
|
screenshotHtml = `
|
|
<div class="response-screenshot">
|
|
<img src="${escapeHtml(response.screenshot_url)}"
|
|
alt="Response screenshot"
|
|
data-image-url="${escapeHtml(response.screenshot_url)}"
|
|
class="screenshot-image">
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
|
|
|
|
// Add verify button HTML if response is unverified and has representative email
|
|
let verifyButtonHtml = '';
|
|
if (!response.is_verified && response.representative_email) {
|
|
// Show button if we have a representative email, regardless of whether verification was initially requested
|
|
verifyButtonHtml = `
|
|
<button class="verify-btn" data-response-id="${response.id}" data-verification-token="${escapeHtml(response.verification_token || '')}" data-rep-email="${escapeHtml(response.representative_email)}">
|
|
<span class="verify-icon">📧</span>
|
|
<span class="verify-text">Send Verification Email</span>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
card.innerHTML = `
|
|
<div class="response-header">
|
|
<div class="response-rep-info">
|
|
<h3>${escapeHtml(response.representative_name)}</h3>
|
|
<div class="rep-meta">
|
|
${response.representative_title ? `<span>${escapeHtml(response.representative_title)}</span>` : ''}
|
|
<span>${createdDate}</span>
|
|
</div>
|
|
</div>
|
|
<div class="response-badges">
|
|
${badges}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="response-content">
|
|
<div class="response-text">${escapeHtml(response.response_text)}</div>
|
|
${userCommentHtml}
|
|
${screenshotHtml}
|
|
</div>
|
|
|
|
<div class="response-footer">
|
|
<div class="response-meta">
|
|
Submitted by ${submittedBy}
|
|
</div>
|
|
<div class="response-actions">
|
|
<button class="upvote-btn ${upvoteClass}" data-response-id="${response.id}">
|
|
<span class="upvote-icon">👍</span>
|
|
<span class="upvote-count">${response.upvote_count || 0}</span>
|
|
</button>
|
|
${verifyButtonHtml}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add event listener for upvote button
|
|
const upvoteBtn = card.querySelector('.upvote-btn');
|
|
upvoteBtn.addEventListener('click', function() {
|
|
toggleUpvote(response.id, this);
|
|
});
|
|
|
|
// Add event listener for verify button if present
|
|
const verifyBtn = card.querySelector('.verify-btn');
|
|
if (verifyBtn) {
|
|
verifyBtn.addEventListener('click', function() {
|
|
handleVerifyClick(response.id, this.dataset.verificationToken, this.dataset.repEmail);
|
|
});
|
|
}
|
|
|
|
// Add event listener for screenshot image if present
|
|
const screenshotImg = card.querySelector('.screenshot-image');
|
|
if (screenshotImg) {
|
|
screenshotImg.addEventListener('click', function() {
|
|
viewImage(this.dataset.imageUrl);
|
|
});
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
// Toggle upvote
|
|
async function toggleUpvote(responseId, button) {
|
|
const isUpvoted = button.classList.contains('upvoted');
|
|
const url = `/api/responses/${responseId}/upvote`;
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: isUpvoted ? 'DELETE' : 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
// Update button state
|
|
button.classList.toggle('upvoted');
|
|
button.querySelector('.upvote-count').textContent = data.upvoteCount;
|
|
|
|
// Reload stats
|
|
loadResponseStats();
|
|
} else {
|
|
showError(data.error || 'Failed to update upvote');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error toggling upvote:', error);
|
|
showError('Failed to update upvote');
|
|
}
|
|
}
|
|
|
|
// Load more responses
|
|
function loadMoreResponses() {
|
|
currentOffset += LIMIT;
|
|
loadResponses(false);
|
|
}
|
|
|
|
// Open submit modal
|
|
function openSubmitModal() {
|
|
console.log('openSubmitModal called');
|
|
const modal = document.getElementById('submit-modal');
|
|
if (modal) {
|
|
modal.style.display = 'block';
|
|
console.log('Modal displayed');
|
|
} else {
|
|
console.error('Modal element not found');
|
|
}
|
|
}
|
|
|
|
// Close submit modal
|
|
function closeSubmitModal() {
|
|
document.getElementById('submit-modal').style.display = 'none';
|
|
document.getElementById('submit-response-form').reset();
|
|
|
|
// Reset postal code lookup
|
|
document.getElementById('rep-select-group').style.display = 'none';
|
|
document.getElementById('rep-select').innerHTML = '';
|
|
loadedRepresentatives = [];
|
|
|
|
// Reset hidden fields
|
|
document.getElementById('representative-email').value = '';
|
|
|
|
// Reset verification checkbox
|
|
const verificationCheckbox = document.getElementById('send-verification');
|
|
verificationCheckbox.disabled = false;
|
|
verificationCheckbox.checked = false;
|
|
}
|
|
|
|
// Handle response submission
|
|
async function handleSubmitResponse(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = new FormData(e.target);
|
|
|
|
// Note: Both send_verification checkbox and representative_email hidden field
|
|
// are already included in FormData from the form
|
|
// send_verification will be 'on' if checked, undefined if not checked
|
|
// representative_email will be populated by handleRepresentativeSelect()
|
|
|
|
// Get verification status for UI feedback
|
|
const sendVerification = document.getElementById('send-verification').checked;
|
|
const repEmail = document.getElementById('representative-email').value;
|
|
|
|
try {
|
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
let message = data.message || 'Response submitted successfully! It will appear after moderation.';
|
|
if (sendVerification && repEmail) {
|
|
message += ' A verification email has been sent to the representative.';
|
|
}
|
|
showSuccess(message);
|
|
closeSubmitModal();
|
|
// Don't reload responses since submission is pending approval
|
|
} else {
|
|
showError(data.error || 'Failed to submit response');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error submitting response:', error);
|
|
showError('Failed to submit response');
|
|
}
|
|
}
|
|
|
|
// Handle verify button click
|
|
async function handleVerifyClick(responseId, verificationToken, representativeEmail) {
|
|
// Mask email to show only first 3 characters and domain
|
|
// e.g., "john.doe@example.com" becomes "joh***@example.com"
|
|
const maskEmail = (email) => {
|
|
const [localPart, domain] = email.split('@');
|
|
if (localPart.length <= 3) {
|
|
return `${localPart}***@${domain}`;
|
|
}
|
|
return `${localPart.substring(0, 3)}***@${domain}`;
|
|
};
|
|
|
|
const maskedEmail = maskEmail(representativeEmail);
|
|
|
|
// Step 1: Prompt the representative to verify their identity by entering their email
|
|
const emailPrompt = prompt(
|
|
'To send a verification email, please enter the representative\'s email address.\n\n' +
|
|
'This email must match the representative email on file for this response.\n\n' +
|
|
`Email on file: ${maskedEmail}`,
|
|
''
|
|
);
|
|
|
|
// User cancelled
|
|
if (emailPrompt === null) {
|
|
return;
|
|
}
|
|
|
|
// Trim and lowercase for comparison
|
|
const enteredEmail = emailPrompt.trim().toLowerCase();
|
|
const storedEmail = representativeEmail.trim().toLowerCase();
|
|
|
|
// Check if email is empty
|
|
if (!enteredEmail) {
|
|
showError('Email address is required to send verification.');
|
|
return;
|
|
}
|
|
|
|
// Validate email format
|
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
if (!emailRegex.test(enteredEmail)) {
|
|
showError('Please enter a valid email address.');
|
|
return;
|
|
}
|
|
|
|
// Check if email matches
|
|
if (enteredEmail !== storedEmail) {
|
|
showError(
|
|
'The email you entered does not match the representative email on file.\n\n' +
|
|
`Expected: ${representativeEmail}\n` +
|
|
`You entered: ${emailPrompt.trim()}\n\n` +
|
|
'Verification email cannot be sent.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Step 2: Email matches - confirm sending verification email
|
|
const confirmSend = confirm(
|
|
'Email verified! Ready to send verification email.\n\n' +
|
|
`A verification email will be sent to: ${representativeEmail}\n\n` +
|
|
'The representative will receive an email with a link to verify this response as authentic.\n\n' +
|
|
'Do you want to send the verification email?'
|
|
);
|
|
|
|
if (!confirmSend) {
|
|
return;
|
|
}
|
|
|
|
// Make request to resend verification email
|
|
try {
|
|
const response = await fetch(`/api/responses/${responseId}/resend-verification`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok && data.success) {
|
|
showSuccess(
|
|
'Verification email sent successfully!\n\n' +
|
|
`An email has been sent to ${representativeEmail} with a verification link.\n\n` +
|
|
'The representative must click the link in the email to complete verification.'
|
|
);
|
|
} else {
|
|
showError(data.error || 'Failed to send verification email. Please try again.');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending verification email:', error);
|
|
showError('An error occurred while sending the verification email. Please try again.');
|
|
}
|
|
}
|
|
|
|
// View image in modal/new tab
|
|
function viewImage(url) {
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
// Utility functions
|
|
function showLoading(show) {
|
|
document.getElementById('loading').style.display = show ? 'block' : 'none';
|
|
}
|
|
|
|
function showEmptyState() {
|
|
document.getElementById('empty-state').style.display = 'block';
|
|
document.getElementById('responses-container').innerHTML = '';
|
|
document.getElementById('load-more-container').style.display = 'none';
|
|
}
|
|
|
|
function showError(message) {
|
|
// Simple alert for now - could integrate with existing error display system
|
|
alert('Error: ' + message);
|
|
}
|
|
|
|
function showSuccess(message) {
|
|
// Simple alert for now - could integrate with existing success display system
|
|
alert(message);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
if (!text) return '';
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Close modal when clicking outside
|
|
window.onclick = function(event) {
|
|
const modal = document.getElementById('submit-modal');
|
|
if (event.target === modal) {
|
|
closeSubmitModal();
|
|
}
|
|
};
|