freealberta/influence/app/public/js/representatives-display.js

508 lines
20 KiB
JavaScript

// Representatives Display Module
class RepresentativesDisplay {
constructor() {
this.container = document.getElementById('representatives-container');
}
displayRepresentatives(representatives) {
if (!representatives || representatives.length === 0) {
this.container.innerHTML = `
<div class="rep-category">
<h3>No Representatives Found</h3>
<p>No representatives were found for this postal code. This might be due to:</p>
<ul>
<li>The postal code is not in our database</li>
<li>Temporary API issues</li>
<li>The postal code is not currently assigned to electoral districts</li>
</ul>
<p>Please try again later or verify your postal code.</p>
</div>
`;
return;
}
// Group representatives by level/type
const grouped = this.groupRepresentatives(representatives);
let html = '';
// Order of importance for display
const displayOrder = [
'Federal',
'Provincial',
'Municipal',
'School Board',
'Other'
];
displayOrder.forEach(level => {
if (grouped[level] && grouped[level].length > 0) {
html += this.renderRepresentativeCategory(level, grouped[level]);
}
});
this.container.innerHTML = html;
this.attachEventListeners();
}
groupRepresentatives(representatives) {
const groups = {
'Federal': [],
'Provincial': [],
'Municipal': [],
'School Board': [],
'Other': []
};
representatives.forEach(rep => {
const setName = rep.representative_set_name || '';
const office = rep.elected_office || '';
if (setName.toLowerCase().includes('house of commons') ||
setName.toLowerCase().includes('federal') ||
office.toLowerCase().includes('member of parliament') ||
office.toLowerCase().includes('mp')) {
groups['Federal'].push(rep);
} else if (setName.toLowerCase().includes('provincial') ||
setName.toLowerCase().includes('legislative assembly') ||
setName.toLowerCase().includes('mla') ||
office.toLowerCase().includes('mla')) {
groups['Provincial'].push(rep);
} else if (setName.toLowerCase().includes('municipal') ||
setName.toLowerCase().includes('city council') ||
setName.toLowerCase().includes('mayor') ||
office.toLowerCase().includes('councillor') ||
office.toLowerCase().includes('mayor')) {
groups['Municipal'].push(rep);
} else if (setName.toLowerCase().includes('school') ||
office.toLowerCase().includes('school') ||
office.toLowerCase().includes('trustee')) {
groups['School Board'].push(rep);
} else {
groups['Other'].push(rep);
}
});
return groups;
}
renderRepresentativeCategory(categoryName, representatives) {
const cards = representatives.map(rep => this.renderRepresentativeCard(rep)).join('');
return `
<div class="rep-category">
<h3>${categoryName} Representatives</h3>
<div class="rep-cards">
${cards}
</div>
</div>
`;
}
renderRepresentativeCard(rep) {
const name = rep.name || 'Name not available';
const email = rep.email || null;
const office = rep.elected_office || 'Office not specified';
const district = rep.district_name || 'District not specified';
const party = rep.party_name || 'Party not specified';
const photoUrl = rep.photo_url || null;
// Extract phone numbers from offices array
const phoneNumbers = this.extractPhoneNumbers(rep.offices || []);
const primaryPhone = phoneNumbers.length > 0 ? phoneNumbers[0] : null;
const emailButton = email ?
`<button class="btn btn-primary compose-email"
data-email="${email}"
data-name="${name}"
data-office="${office}"
data-district="${district}">
📧 Send Email
</button>` :
'<span class="text-muted">No email available</span>';
// Add call button if phone number is available
const callButton = primaryPhone ?
`<button class="btn btn-success call-representative"
data-phone="${primaryPhone.number}"
data-office-type="${primaryPhone.type}"
data-name="${name}"
data-office="${office}">
📞 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>` : '';
// Generate initials for fallback
const initials = name.split(' ')
.map(word => word.charAt(0))
.join('')
.toUpperCase()
.slice(0, 2);
const photoElement = photoUrl ?
`<div class="rep-photo">
<img src="${photoUrl}"
alt="${name}"
data-fallback-initials="${initials}"
loading="lazy">
<div class="rep-photo-fallback" style="display: none;">
${initials}
</div>
</div>` :
`<div class="rep-photo">
<div class="rep-photo-fallback">
${initials}
</div>
</div>`;
return `
<div class="rep-card">
${photoElement}
<div class="rep-content">
<div class="rep-header">
<h4>${name}</h4>
</div>
<div class="rep-info">
<p><strong>Office:</strong> ${office}</p>
<p><strong>District:</strong> ${district}</p>
${party !== 'Party not specified' ? `<p><strong>Party:</strong> ${party}</p>` : ''}
${email ? `<p><strong>Email:</strong> ${email}</p>` : ''}
${primaryPhone ? `<p><strong>Phone:</strong> ${primaryPhone.number} ${primaryPhone.type ? `(${primaryPhone.type})` : ''}</p>` : ''}
</div>
<div class="rep-actions">
${emailButton}
${callButton}
${profileUrl}
</div>
${visitButtons ? `<div class="rep-visit-buttons">${visitButtons}</div>` : ''}
</div>
</div>
`;
}
extractPhoneNumbers(offices) {
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 && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
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');
composeButtons.forEach(button => {
button.addEventListener('click', (e) => {
const email = e.target.dataset.email;
const name = e.target.dataset.name;
const office = e.target.dataset.office;
const district = e.target.dataset.district;
window.emailComposer.openModal({
email,
name,
office,
district
});
});
});
// Add event listeners for call buttons
const callButtons = this.container.querySelectorAll('.call-representative');
callButtons.forEach(button => {
button.addEventListener('click', (e) => {
const phone = e.target.dataset.phone;
const name = e.target.dataset.name;
const office = e.target.dataset.office;
const officeType = e.target.dataset.officeType;
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) {
// Clean the phone number for tel: link (remove spaces, dashes, parentheses)
const cleanPhone = phone.replace(/[\s\-\(\)]/g, '');
// Create tel: link
const telLink = `tel:${cleanPhone}`;
// Show confirmation dialog with formatted information
const officeInfo = officeType ? ` (${officeType} office)` : '';
const message = `Call ${name}${officeInfo}?\n\nPhone: ${phone}`;
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
// Track the call
this.trackCall(phone, name, office, officeType);
}
}
async trackCall(phone, name, office, officeType) {
try {
await fetch('/api/track-call', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name,
representativeTitle: office || '',
phoneNumber: phone,
officeType: officeType || '',
postalCode: window.lastLookupPostalCode || null,
userEmail: null,
userName: null
})
});
} catch (error) {
console.error('Failed to track call:', error);
// Don't show error to user - tracking is non-critical
}
}
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
document.addEventListener('DOMContentLoaded', () => {
window.representativesDisplay = new RepresentativesDisplay();
});