freealberta/influence/app/public/js/response-wall.js

890 lines
30 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;
// 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');
});
// 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();
}
};