Bunch of buag fixes and updates

This commit is contained in:
2025-09-24 12:37:26 -06:00
parent feb2e72c9a
commit f35a1f4be5
33 changed files with 3085 additions and 200 deletions

View File

@@ -251,6 +251,7 @@ class AdminPanel {
email_subject: formData.get('email_subject'),
email_body: formData.get('email_body'),
call_to_action: formData.get('call_to_action'),
status: formData.get('status'),
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
collect_user_info: formData.get('collect_user_info') === 'on',
@@ -302,6 +303,7 @@ class AdminPanel {
form.querySelector('[name="allow_mailto_link"]').checked = campaign.allow_mailto_link;
form.querySelector('[name="collect_user_info"]').checked = campaign.collect_user_info;
form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count;
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
// Government levels
const targetLevels = campaign.target_government_levels ?

View File

@@ -71,6 +71,11 @@ class APIClient {
async sendEmail(emailData) {
return this.post('/emails/send', emailData);
}
// Preview email before sending
async previewEmail(emailData) {
return this.post('/emails/preview', emailData);
}
}
// Create global instance

View File

@@ -66,10 +66,8 @@ class CampaignPage {
document.getElementById('call-to-action').style.display = 'block';
}
// Show email preview
document.getElementById('preview-subject').textContent = this.campaign.email_subject;
document.getElementById('preview-body').textContent = this.campaign.email_body;
document.getElementById('email-preview').style.display = 'block';
// Set up email preview
this.setupEmailPreview();
// Set up email method options
this.setupEmailMethodOptions();
@@ -122,6 +120,115 @@ class CampaignPage {
}
}
setupEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewDescription = document.getElementById('preview-description');
// Store original email content
this.originalEmailSubject = this.campaign.email_subject;
this.originalEmailBody = this.campaign.email_body;
this.currentEmailSubject = this.campaign.email_subject;
this.currentEmailBody = this.campaign.email_body;
// Set up preview content
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Set up editable fields
document.getElementById('edit-subject').value = this.currentEmailSubject;
document.getElementById('edit-body').value = this.currentEmailBody;
if (this.campaign.allow_email_editing) {
// Enable editing mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewDescription.textContent = 'You can edit this message before sending to your representatives:';
// Set up event listeners for editing
this.setupEmailEditingListeners();
} else {
// Read-only preview mode
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewDescription.textContent = 'This is the message that will be sent to your representatives:';
}
emailPreview.style.display = 'block';
}
setupEmailEditingListeners() {
const editSubject = document.getElementById('edit-subject');
const editBody = document.getElementById('edit-body');
const previewBtn = document.getElementById('preview-email-btn');
const saveBtn = document.getElementById('save-email-btn');
// Auto-update current content as user types
editSubject.addEventListener('input', (e) => {
this.currentEmailSubject = e.target.value;
});
editBody.addEventListener('input', (e) => {
this.currentEmailBody = e.target.value;
});
// Preview button - toggle between edit and preview mode
previewBtn.addEventListener('click', () => {
this.toggleEmailPreview();
});
// Save button - save changes
saveBtn.addEventListener('click', () => {
this.saveEmailChanges();
});
}
toggleEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
if (emailPreview.classList.contains('edit-mode')) {
// Switch to preview mode
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
} else {
// Switch to edit mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewBtn.textContent = '👁️ Preview';
}
}
saveEmailChanges() {
// Update the current values and show confirmation
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Show success message
this.showMessage('Email content updated successfully!', 'success');
// Switch to preview mode
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
}
showMessage(message, type = 'info') {
// Use existing message display system if available
if (window.messageDisplay) {
window.messageDisplay.show(message, type);
} else {
// Fallback to alert
alert(message);
}
}
formatPostalCode(e) {
let value = e.target.value.replace(/\s/g, '').toUpperCase();
if (value.length > 3) {
@@ -364,8 +471,8 @@ class CampaignPage {
}
openMailtoLink(recipientEmail) {
const subject = encodeURIComponent(this.campaign.email_subject);
const body = encodeURIComponent(this.campaign.email_body);
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
@@ -378,21 +485,29 @@ class CampaignPage {
this.showLoading('Sending email...');
try {
const emailData = {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod: 'smtp'
};
// Include custom email content if email editing is enabled
if (this.campaign.allow_email_editing) {
emailData.customEmailSubject = this.currentEmailSubject || this.campaign.email_subject;
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
}
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod: 'smtp'
})
body: JSON.stringify(emailData)
});
const data = await response.json();

View File

@@ -2,34 +2,67 @@
class EmailComposer {
constructor() {
this.modal = document.getElementById('email-modal');
this.previewModal = document.getElementById('email-preview-modal');
this.form = document.getElementById('email-form');
this.closeBtn = document.getElementById('close-modal');
this.closePreviewBtn = document.getElementById('close-preview-modal');
this.cancelBtn = document.getElementById('cancel-email');
this.cancelPreviewBtn = document.getElementById('cancel-preview');
this.editBtn = document.getElementById('edit-email');
this.confirmSendBtn = document.getElementById('confirm-send');
this.messageTextarea = document.getElementById('email-message');
this.charCounter = document.querySelector('.char-counter');
this.currentRecipient = null;
this.currentEmailData = null;
this.lastPreviewTime = 0; // Track last preview request time
this.init();
}
init() {
// Modal controls
this.closeBtn.addEventListener('click', () => this.closeModal());
this.closePreviewBtn.addEventListener('click', () => this.closePreviewModal());
this.cancelBtn.addEventListener('click', () => this.closeModal());
this.cancelPreviewBtn.addEventListener('click', () => this.closePreviewModal());
this.editBtn.addEventListener('click', () => this.editEmail());
this.confirmSendBtn.addEventListener('click', () => this.confirmSend());
// Click outside modal to close
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.closeModal();
});
this.previewModal.addEventListener('click', (e) => {
if (e.target === this.previewModal) this.closePreviewModal();
});
// Form handling
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Form handling - now shows preview instead of sending directly
this.form.addEventListener('submit', (e) => this.handlePreview(e));
// Character counter
this.messageTextarea.addEventListener('input', () => this.updateCharCounter());
// Escape key to close modal
// Add event listener to sender name field to update subject dynamically
const senderNameField = document.getElementById('sender-name');
const subjectField = document.getElementById('email-subject');
const postalCodeField = document.getElementById('sender-postal-code');
senderNameField.addEventListener('input', () => {
const senderName = senderNameField.value.trim() || 'your constituent';
const postalCode = postalCodeField.value.trim();
if (postalCode) {
subjectField.value = `Message from ${senderName} from ${postalCode}`;
}
});
// Escape key to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modal.style.display === 'block') {
this.closeModal();
if (e.key === 'Escape') {
if (this.previewModal.style.display === 'block') {
this.closePreviewModal();
} else if (this.modal.style.display === 'block') {
this.closeModal();
}
}
});
}
@@ -57,7 +90,7 @@ class EmailComposer {
document.getElementById('email-message').value = '';
// Set default subject
document.getElementById('email-subject').value = `Message from your constituent in ${postalCode}`;
document.getElementById('email-subject').value = `Message from your constituent from ${postalCode}`;
this.updateCharCounter();
this.modal.style.display = 'block';
@@ -68,7 +101,24 @@ class EmailComposer {
closeModal() {
this.modal.style.display = 'none';
// Only clear data if we're not showing preview (user is canceling)
if (this.previewModal.style.display !== 'block') {
this.currentRecipient = null;
this.currentEmailData = null;
}
}
closePreviewModal() {
this.previewModal.style.display = 'none';
// Clear email data when closing preview (user canceling)
this.currentRecipient = null;
this.currentEmailData = null;
}
editEmail() {
// Close preview modal and return to compose modal without clearing data
this.previewModal.style.display = 'none';
this.modal.style.display = 'block';
}
updateCharCounter() {
@@ -141,9 +191,17 @@ class EmailComposer {
return suspiciousPatterns.some(pattern => pattern.test(text));
}
async handleSubmit(e) {
async handlePreview(e) {
e.preventDefault();
// Prevent duplicate calls within 2 seconds
const currentTime = Date.now();
if (currentTime - this.lastPreviewTime < 2000) {
console.log('Preview request blocked - too soon since last request');
return;
}
this.lastPreviewTime = currentTime;
const errors = this.validateForm();
if (errors.length > 0) {
window.messageDisplay.show(errors.join('<br>'), 'error');
@@ -155,22 +213,118 @@ class EmailComposer {
try {
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
submitButton.textContent = 'Loading Preview...';
const emailData = {
this.currentEmailData = {
recipientEmail: document.getElementById('recipient-email').value,
senderName: document.getElementById('sender-name').value.trim(),
senderEmail: document.getElementById('sender-email').value.trim(),
subject: document.getElementById('email-subject').value.trim(),
message: document.getElementById('email-message').value.trim(),
postalCode: document.getElementById('sender-postal-code').value
postalCode: document.getElementById('sender-postal-code').value,
recipientName: this.currentRecipient ? this.currentRecipient.name : null
};
const result = await window.apiClient.sendEmail(emailData);
const preview = await window.apiClient.previewEmail(this.currentEmailData);
if (preview.success) {
this.showPreview(preview.preview);
this.previewModal.style.display = 'block'; // Show preview modal first
this.closeModal(); // Close the compose modal
} else {
throw new Error(preview.message || 'Failed to generate preview');
}
} catch (error) {
console.error('Email preview failed:', error);
window.messageDisplay.show(`Failed to generate preview: ${error.message}`, 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
showPreview(preview) {
// Populate preview modal with email details
document.getElementById('preview-recipient').textContent = preview.to;
document.getElementById('preview-sender').textContent = `${this.currentEmailData.senderName} <${this.currentEmailData.senderEmail}>`;
document.getElementById('preview-subject').textContent = preview.subject;
// Show the HTML preview content using iframe for complete isolation
const previewContent = document.getElementById('preview-content');
if (preview.html) {
// Use iframe to completely isolate the email HTML from the parent page
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.minHeight = '400px';
iframe.style.border = '1px solid #dee2e6';
iframe.style.borderRadius = '6px';
iframe.style.backgroundColor = '#ffffff';
iframe.sandbox = 'allow-same-origin'; // Safe sandbox settings
// Clear previous content and add iframe
previewContent.innerHTML = '';
previewContent.appendChild(iframe);
// Write the HTML content to the iframe
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(preview.html);
iframeDoc.close();
// Auto-resize iframe to content height
iframe.onload = () => {
try {
const body = iframe.contentDocument.body;
const html = iframe.contentDocument.documentElement;
const height = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
iframe.style.height = Math.min(height + 20, 600) + 'px'; // Max height of 600px
} catch (e) {
// Fallback height if auto-resize fails
iframe.style.height = '400px';
}
};
} else if (preview.text) {
previewContent.innerHTML = `<pre style="white-space: pre-wrap; font-family: inherit; padding: 20px; background-color: #f8f9fa; border-radius: 6px; border: 1px solid #dee2e6;">${this.escapeHtml(preview.text)}</pre>`;
} else {
previewContent.innerHTML = '<p style="padding: 20px; text-align: center; color: #666;">No preview content available</p>';
}
}
// Escape HTML to prevent injection when showing text content
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async confirmSend() {
if (!this.currentEmailData) {
window.messageDisplay.show('No email data to send', 'error');
return;
}
const confirmButton = this.confirmSendBtn;
const originalText = confirmButton.textContent;
try {
confirmButton.disabled = true;
confirmButton.textContent = 'Sending...';
const result = await window.apiClient.sendEmail(this.currentEmailData);
if (result.success) {
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
this.closeModal();
this.closePreviewModal();
this.currentEmailData = null;
} else {
throw new Error(result.message || 'Failed to send email');
}
@@ -195,8 +349,8 @@ class EmailComposer {
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
}
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
confirmButton.disabled = false;
confirmButton.textContent = originalText;
}
}

View File

@@ -51,7 +51,7 @@ class EmailTesting {
try {
const response = await this.apiClient.post('/api/emails/test', {
subject: 'Quick Test Email',
message: 'This is a quick test email sent from the Alberta Influence Campaign Tool email testing interface.'
message: 'This is a quick test email sent from the BNKops Influence Campaign Tool email testing interface.'
});
if (response.success) {

View File

@@ -13,15 +13,38 @@ class MainApp {
// Add global error handling
window.addEventListener('error', (e) => {
console.error('Global error:', e.error);
window.messageDisplay.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
// Only log and show message for actual errors, not null/undefined
if (e.error) {
console.error('Global error:', e.error);
console.error('Error details:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error
});
window.messageDisplay?.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
} else {
// Just log these non-critical errors without showing popup
console.log('Non-critical error event:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
type: e.type
});
}
});
// Add unhandled promise rejection handling
window.addEventListener('unhandledrejection', (e) => {
console.error('Unhandled promise rejection:', e.reason);
window.messageDisplay.show('An unexpected error occurred. Please try again.', 'error');
e.preventDefault();
if (e.reason) {
console.error('Unhandled promise rejection:', e.reason);
window.messageDisplay?.show('An unexpected error occurred. Please try again.', 'error');
e.preventDefault();
} else {
console.log('Non-critical promise rejection:', e);
}
});
}

View File

@@ -136,9 +136,8 @@ class PostalLookup {
locationText += `${data.city}, ${data.province}`;
}
if (data.source || apiResponse.source) {
locationText += `Data source: ${data.source || apiResponse.source}`;
locationText += `Pulled From: ${data.source || apiResponse.source}`;
}
locationText += ` • Data source: api`;
this.locationDetails.textContent = locationText;
// Show representatives

View File

@@ -117,7 +117,7 @@ class RepresentativesDisplay {
data-name="${name}"
data-office="${office}"
data-district="${district}">
Send Email
📧 Send Email
</button>` :
'<span class="text-muted">No email available</span>';
@@ -131,8 +131,11 @@ class RepresentativesDisplay {
📞 Call
</button>` : '';
// Add visit buttons for all available office addresses
const visitButtons = this.createVisitButtons(rep.offices || [], name, office);
const profileUrl = rep.url ?
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">View Profile</a>` : '';
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">👤 View Profile</a>` : '';
// Generate initials for fallback
const initials = name.split(' ')
@@ -145,7 +148,7 @@ class RepresentativesDisplay {
`<div class="rep-photo">
<img src="${photoUrl}"
alt="${name}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
data-fallback-initials="${initials}"
loading="lazy">
<div class="rep-photo-fallback" style="display: none;">
${initials}
@@ -176,6 +179,7 @@ class RepresentativesDisplay {
${callButton}
${profileUrl}
</div>
${visitButtons ? `<div class="rep-visit-buttons">${visitButtons}</div>` : ''}
</div>
</div>
`;
@@ -185,8 +189,63 @@ class RepresentativesDisplay {
const phoneNumbers = [];
if (Array.isArray(offices)) {
// Priority order for office types (prefer local over remote)
const officePriorities = ['constituency', 'district', 'local', 'regional', 'legislature'];
// First, try to find offices with Alberta addresses (for MPs)
const albertaOffices = offices.filter(office => {
const address = office.postal || office.address || '';
return address.toLowerCase().includes('alberta') ||
address.toLowerCase().includes(' ab ') ||
address.toLowerCase().includes('edmonton') ||
address.toLowerCase().includes('calgary') ||
address.toLowerCase().includes('red deer') ||
address.toLowerCase().includes('lethbridge') ||
address.toLowerCase().includes('medicine hat');
});
// Add phone numbers from Alberta offices first
if (albertaOffices.length > 0) {
for (const priority of officePriorities) {
const priorityOffice = albertaOffices.find(office =>
office.type === priority && office.tel
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Add any remaining Alberta office phone numbers
albertaOffices.forEach(office => {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
// Then add phone numbers from other offices by priority
for (const priority of officePriorities) {
const priorityOffice = offices.find(office =>
office.type === priority && office.tel &&
!phoneNumbers.find(p => p.number === office.tel)
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Finally, add any remaining phone numbers
offices.forEach(office => {
if (office.tel) {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
@@ -198,6 +257,102 @@ class RepresentativesDisplay {
return phoneNumbers;
}
createVisitButtons(offices, repName, repOffice) {
if (!Array.isArray(offices) || offices.length === 0) {
return '';
}
const validOffices = offices.filter(office => office.postal || office.address);
if (validOffices.length === 0) {
return '';
}
// Sort offices by priority (local first)
const sortedOffices = validOffices.sort((a, b) => {
const aAddress = (a.postal || a.address || '').toLowerCase();
const bAddress = (b.postal || b.address || '').toLowerCase();
// Check if address is in Alberta
const aIsAlberta = aAddress.includes('alberta') || aAddress.includes(' ab ') ||
aAddress.includes('edmonton') || aAddress.includes('calgary');
const bIsAlberta = bAddress.includes('alberta') || bAddress.includes(' ab ') ||
bAddress.includes('edmonton') || bAddress.includes('calgary');
if (aIsAlberta && !bIsAlberta) return -1;
if (!aIsAlberta && bIsAlberta) return 1;
// If both are Alberta or both are not, prefer constituency over legislature
const typePriority = { 'constituency': 1, 'district': 2, 'local': 3, 'regional': 4, 'legislature': 5 };
const aPriority = typePriority[a.type] || 6;
const bPriority = typePriority[b.type] || 6;
return aPriority - bPriority;
});
return sortedOffices.map(office => {
const address = office.postal || office.address;
const officeType = this.getOfficeTypeLabel(office.type, address);
const isLocal = this.isLocalAddress(address);
return `
<button class="btn btn-sm btn-secondary visit-office"
data-address="${address}"
data-name="${repName}"
data-office="${repOffice}"
title="Visit ${officeType} office">
🗺️ ${officeType}${isLocal ? ' 📍' : ''}
<small class="office-location">${this.getShortAddress(address)}</small>
</button>
`;
}).join('');
}
getOfficeTypeLabel(type, address) {
if (!type) {
// Try to determine type from address
const addr = address.toLowerCase();
if (addr.includes('ottawa') || addr.includes('parliament') || addr.includes('house of commons')) {
return 'Ottawa';
} else if (addr.includes('legislature') || addr.includes('provincial')) {
return 'Legislature';
} else if (addr.includes('city hall')) {
return 'City Hall';
}
return 'Office';
}
const typeLabels = {
'constituency': 'Local Office',
'district': 'District Office',
'local': 'Local Office',
'regional': 'Regional Office',
'legislature': 'Legislature'
};
return typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1);
}
isLocalAddress(address) {
const addr = address.toLowerCase();
return addr.includes('alberta') || addr.includes(' ab ') ||
addr.includes('edmonton') || addr.includes('calgary') ||
addr.includes('red deer') || addr.includes('lethbridge') ||
addr.includes('medicine hat');
}
getShortAddress(address) {
// Extract city and province/state for short display
const parts = address.split(',');
if (parts.length >= 2) {
const city = parts[parts.length - 2].trim();
const province = parts[parts.length - 1].trim();
return `${city}, ${province}`;
}
// Fallback: just show first part
return parts[0].trim();
}
attachEventListeners() {
// Add event listeners for compose email buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
@@ -229,6 +384,33 @@ class RepresentativesDisplay {
this.handleCallClick(phone, name, office, officeType);
});
});
// Add event listeners for visit buttons
const visitButtons = this.container.querySelectorAll('.visit-office');
visitButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
// Use currentTarget to ensure we get the button, not nested elements
const address = button.dataset.address;
const name = button.dataset.name;
const office = button.dataset.office;
this.handleVisitClick(address, name, office);
});
});
// Add event listeners for image error handling
const repImages = this.container.querySelectorAll('.rep-photo img');
repImages.forEach(img => {
img.addEventListener('error', (e) => {
// Hide the image and show the fallback
e.target.style.display = 'none';
const fallback = e.target.nextElementSibling;
if (fallback && fallback.classList.contains('rep-photo-fallback')) {
fallback.style.display = 'flex';
}
});
});
}
handleCallClick(phone, name, office, officeType) {
@@ -247,6 +429,51 @@ class RepresentativesDisplay {
window.location.href = telLink;
}
}
handleVisitClick(address, name, office) {
// Clean and format the address for URL encoding
const cleanAddress = address.replace(/\n/g, ', ').trim();
// Show confirmation dialog
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;
if (confirm(message)) {
// Create maps URL - this will work on most platforms
// For mobile devices, it will open the default maps app
// For desktop, it will open Google Maps in browser
const encodedAddress = encodeURIComponent(cleanAddress);
// Try different map services based on user agent
const userAgent = navigator.userAgent.toLowerCase();
let mapsUrl;
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
// iOS - use Apple Maps
mapsUrl = `maps://maps.apple.com/?q=${encodedAddress}`;
// Fallback to Google Maps if Apple Maps doesn't work
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else if (userAgent.includes('android')) {
// Android - use Google Maps app if available
mapsUrl = `geo:0,0?q=${encodedAddress}`;
// Fallback to Google Maps web
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else {
// Desktop or other - open Google Maps in new tab
mapsUrl = `https://www.google.com/maps/search/${encodedAddress}`;
window.open(mapsUrl, '_blank');
}
}
}
}
// Initialize when DOM is loaded

View File

@@ -0,0 +1,583 @@
/**
* Representatives Map Module
* Handles map initialization, office location display, and popup cards
*/
// Map state
let representativesMap = null;
let representativeMarkers = [];
let currentPostalCode = null;
// Office location icons
const officeIcons = {
federal: L.divIcon({
className: 'office-marker federal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
provincial: L.divIcon({
className: 'office-marker provincial',
html: '<div class="marker-content">🏢</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
municipal: L.divIcon({
className: 'office-marker municipal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
})
};
// Initialize the representatives map
function initializeRepresentativesMap() {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) {
console.warn('Map container not found');
return;
}
// Avoid double initialization
if (representativesMap) {
console.log('Map already initialized, invalidating size instead');
representativesMap.invalidateSize();
return;
}
// Check if Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet (L) is not defined. Map initialization failed.');
return;
}
// We'll initialize the map even if not visible, then invalidate size when needed
console.log('Initializing representatives map...');
// Center on Alberta
representativesMap = L.map('main-map').setView([53.9333, -116.5765], 6);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
minZoom: 2
}).addTo(representativesMap);
// Trigger size invalidation after a brief moment to ensure proper rendering
setTimeout(() => {
if (representativesMap) {
representativesMap.invalidateSize();
}
}, 200);
}
// Clear all representative markers from the map
function clearRepresentativeMarkers() {
representativeMarkers.forEach(marker => {
representativesMap.removeLayer(marker);
});
representativeMarkers = [];
}
// Add representative offices to the map
function displayRepresentativeOffices(representatives, postalCode) {
// Initialize map if not already done
if (!representativesMap) {
console.log('Map not initialized, initializing now...');
initializeRepresentativesMap();
}
if (!representativesMap) {
console.error('Failed to initialize map');
return;
}
clearRepresentativeMarkers();
currentPostalCode = postalCode;
const validOffices = [];
let bounds = [];
console.log('Processing representatives for map display:', representatives.length);
// Group representatives by office location to handle shared addresses
const locationGroups = new Map();
representatives.forEach((rep, index) => {
console.log(`Processing representative ${index + 1}:`, rep.name, rep.representative_set_name);
// Try to get office location from various sources
const offices = getOfficeLocations(rep);
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
offices.forEach((office, officeIndex) => {
console.log(`Office ${officeIndex + 1} for ${rep.name}:`, office);
if (office.lat && office.lng) {
const locationKey = `${office.lat.toFixed(6)},${office.lng.toFixed(6)}`;
if (!locationGroups.has(locationKey)) {
locationGroups.set(locationKey, {
lat: office.lat,
lng: office.lng,
address: office.address,
representatives: [],
offices: []
});
}
locationGroups.get(locationKey).representatives.push(rep);
locationGroups.get(locationKey).offices.push(office);
validOffices.push({ rep, office });
} else {
console.log(`No coordinates found for ${rep.name} office:`, office);
}
});
});
// Create markers for each location group
let offsetIndex = 0;
locationGroups.forEach((locationGroup, locationKey) => {
console.log(`Creating markers for location ${locationKey} with ${locationGroup.representatives.length} representatives`);
if (locationGroup.representatives.length === 1) {
// Single representative at this location
const rep = locationGroup.representatives[0];
const office = locationGroup.offices[0];
const marker = createOfficeMarker(rep, office);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([office.lat, office.lng]);
}
} else {
// Multiple representatives at same location - create offset markers
locationGroup.representatives.forEach((rep, repIndex) => {
const office = locationGroup.offices[repIndex];
// Add small offset to avoid exact overlap
const offsetDistance = 0.0005; // About 50 meters
const angle = (repIndex * 2 * Math.PI) / locationGroup.representatives.length;
const offsetLat = office.lat + (offsetDistance * Math.cos(angle));
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
const offsetOffice = {
...office,
lat: offsetLat,
lng: offsetLng
};
console.log(`Creating offset marker for ${rep.name} at ${offsetLat}, ${offsetLng}`);
const marker = createOfficeMarker(rep, offsetOffice, locationGroup.representatives.length > 1);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([offsetLat, offsetLng]);
}
});
}
});
console.log(`Total markers created: ${representativeMarkers.length}`);
console.log(`Bounds array:`, bounds);
// Fit map to show all offices, or center on Alberta if no offices found
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
} else {
// If no office locations found, show a message and keep Alberta view
console.log('No office locations with coordinates found, showing message');
showMapMessage('Office locations not available for representatives in this area.');
}
console.log(`Displayed ${validOffices.length} office locations on map`);
}
// Extract office locations from representative data
function getOfficeLocations(representative) {
const offices = [];
console.log(`Getting office locations for ${representative.name}`);
console.log('Representative offices data:', representative.offices);
// Check various sources for office location data
if (representative.offices && Array.isArray(representative.offices)) {
representative.offices.forEach((office, index) => {
console.log(`Processing office ${index + 1}:`, office);
// Use the 'postal' field which contains the address
if (office.postal || office.address) {
const officeData = {
type: office.type || 'office',
address: office.postal || office.address || 'Office Address',
postal_code: office.postal_code,
phone: office.tel || office.phone,
fax: office.fax,
lat: office.lat,
lng: office.lng
};
console.log('Created office data:', officeData);
offices.push(officeData);
}
});
}
// For all offices without coordinates, add approximate coordinates
offices.forEach(office => {
if (!office.lat || !office.lng) {
console.log(`Adding coordinates to office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
office.lat = approxLocation.lat;
office.lng = approxLocation.lng;
console.log('Updated office with coordinates:', office);
}
}
});
// If no offices found at all, create a fallback office
if (offices.length === 0 && representative.representative_set_name) {
console.log(`No offices found, creating fallback office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
const fallbackOffice = {
type: 'representative',
address: `${representative.name} - ${representative.district_name || representative.representative_set_name}`,
lat: approxLocation.lat,
lng: approxLocation.lng
};
console.log('Created fallback office:', fallbackOffice);
offices.push(fallbackOffice);
}
}
console.log(`Total offices found for ${representative.name}:`, offices.length);
return offices;
}
// Get approximate location based on district and government level
function getApproximateLocationByDistrict(district, level) {
// Specific locations for Edmonton officials
const edmontonLocations = {
// City Hall for municipal officials
'Edmonton': { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
"O-day'min": { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
// Provincial Legislature
'Edmonton-Glenora': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
// Federal offices (approximate downtown Edmonton)
'Edmonton Centre': { lat: 53.5461, lng: -113.4938 }
};
// Try specific district first
if (district && edmontonLocations[district]) {
return edmontonLocations[district];
}
// Fallback based on government level
const levelLocations = {
'House of Commons': { lat: 53.5461, lng: -113.4938 }, // Downtown Edmonton
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Edmonton City Hall
};
return levelLocations[level] || { lat: 53.9333, lng: -116.5765 }; // Default to Alberta center
}
// Create a marker for an office location
function createOfficeMarker(representative, office, isSharedLocation = false) {
if (!office.lat || !office.lng) {
return null;
}
// Determine icon based on government level
let icon = officeIcons.municipal; // default
if (representative.representative_set_name) {
if (representative.representative_set_name.includes('House of Commons')) {
icon = officeIcons.federal;
} else if (representative.representative_set_name.includes('Legislative Assembly')) {
icon = officeIcons.provincial;
}
}
const marker = L.marker([office.lat, office.lng], { icon });
// Create popup content
const popupContent = createOfficePopupContent(representative, office, isSharedLocation);
marker.bindPopup(popupContent, {
maxWidth: 300,
className: 'office-popup'
});
return marker;
}
// Create popup content for office markers
function createOfficePopupContent(representative, office, isSharedLocation = false) {
const level = getRepresentativeLevel(representative.representative_set_name);
const levelClass = level.toLowerCase().replace(' ', '-');
return `
<div class="office-popup-content">
<div class="rep-header ${levelClass}">
${representative.photo_url ? `<img src="${representative.photo_url}" alt="${representative.name}" class="rep-photo-small">` : ''}
<div class="rep-info">
<h4>${representative.name}</h4>
<p class="rep-level">${level}</p>
<p class="rep-district">${representative.district_name || 'District not specified'}</p>
${isSharedLocation ? '<p class="shared-location-note"><small><em>Note: Office location shared with other representatives</em></small></p>' : ''}
</div>
</div>
<div class="office-details">
<h5>Office Information</h5>
${office.address ? `<p><strong>Address:</strong> ${office.address}</p>` : ''}
${office.phone ? `<p><strong>Phone:</strong> <a href="tel:${office.phone}">${office.phone}</a></p>` : ''}
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
${office.postal_code ? `<p><strong>Postal Code:</strong> ${office.postal_code}</p>` : ''}
</div>
<div class="office-actions">
${representative.email ? `<button class="btn btn-primary btn-small email-btn" data-email="${representative.email}" data-name="${representative.name}" data-level="${representative.representative_set_name}">Send Email</button>` : ''}
</div>
</div>
`;
}// Get representative level for display
function getRepresentativeLevel(representativeSetName) {
if (!representativeSetName) return 'Representative';
if (representativeSetName.includes('House of Commons')) {
return 'Federal MP';
} else if (representativeSetName.includes('Legislative Assembly')) {
return 'Provincial MLA';
} else {
return 'Municipal Representative';
}
}
// Show a message on the map
function showMapMessage(message) {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) return;
// Remove any existing message
const existingMessage = mapContainer.querySelector('.map-message');
if (existingMessage) {
existingMessage.remove();
}
// Create and show new message
const messageDiv = document.createElement('div');
messageDiv.className = 'map-message';
messageDiv.innerHTML = `
<div class="map-message-content">
<p>${message}</p>
</div>
`;
mapContainer.appendChild(messageDiv);
// Remove message after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.remove();
}
}, 5000);
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle email button clicks in popups
document.addEventListener('click', (event) => {
if (event.target.classList.contains('email-btn')) {
event.preventDefault();
const email = event.target.dataset.email;
const name = event.target.dataset.name;
const level = event.target.dataset.level;
if (window.openEmailModal) {
window.openEmailModal(email, name, level);
}
}
});
}
// Handle postal code submission and fetch representatives
async function handlePostalCodeSubmission(postalCode) {
try {
showLoading();
hideError();
// Normalize postal code
const normalizedPostalCode = postalCode.toUpperCase().replace(/\s/g, '');
// Fetch representatives data
const response = await fetch(`/api/representatives/by-postal/${normalizedPostalCode}`);
const data = await response.json();
hideLoading();
if (data.success && data.data && data.data.representatives) {
// Display representatives on map
displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
// Also update the representatives display section using the existing system
if (window.representativesDisplay) {
window.representativesDisplay.displayRepresentatives(data.data.representatives);
}
// Update location info manually if the existing system doesn't work
const locationDetails = document.getElementById('location-details');
if (locationDetails && data.data.location) {
const location = data.data.location;
locationDetails.textContent = `${location.city}, ${location.province} (${normalizedPostalCode})`;
} else if (locationDetails) {
locationDetails.textContent = `Postal Code: ${normalizedPostalCode}`;
}
if (window.locationInfo) {
window.locationInfo.updateLocationInfo(data.data.location, normalizedPostalCode);
}
// Show the representatives section
const representativesSection = document.getElementById('representatives-section');
representativesSection.style.display = 'block';
// Fix map rendering after section becomes visible
setTimeout(() => {
if (!representativesMap) {
initializeRepresentativesMap();
}
if (representativesMap) {
representativesMap.invalidateSize();
// Try to fit bounds again if we have markers
if (representativeMarkers.length > 0) {
const bounds = representativeMarkers.map(marker => marker.getLatLng());
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
}
}
}
}, 300);
// Show refresh button
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
refreshBtn.style.display = 'inline-block';
// Store postal code for refresh functionality
refreshBtn.dataset.postalCode = normalizedPostalCode;
}
// Show success message
if (window.messageDisplay) {
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
}
} else {
showError(data.message || 'Unable to find representatives for this postal code.');
}
} catch (error) {
hideLoading();
console.error('Error fetching representatives:', error);
showError('An error occurred while looking up representatives. Please try again.');
}
}
// Utility functions for loading and error states
function showLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'block';
}
function hideLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'none';
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
}
function hideError() {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.style.display = 'none';
}
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
const refreshBtn = document.getElementById('refresh-btn');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle refresh button
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
const postalCode = refreshBtn.dataset.postalCode || document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
}
// Initialize everything when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeRepresentativesMap();
initializePostalForm();
});
// Global function for opening email modal from map popups
window.openEmailModal = function(email, name, level) {
if (window.emailComposer) {
window.emailComposer.openModal({
email: email,
name: name,
level: level
}, currentPostalCode);
}
};
// Export functions for use by other modules
window.RepresentativesMap = {
displayRepresentativeOffices,
initializeRepresentativesMap,
clearRepresentativeMarkers,
handlePostalCodeSubmission
};