Pushing the new influence app in its current state

This commit is contained in:
2025-09-20 11:19:26 -06:00
parent e1f230bd30
commit 91b95ac9ca
37 changed files with 9751 additions and 2 deletions

View File

@@ -0,0 +1,511 @@
// Admin Panel JavaScript
class AdminPanel {
constructor() {
this.currentCampaign = null;
this.campaigns = [];
this.authManager = null;
}
async init() {
// Check authentication first
if (typeof authManager !== 'undefined') {
this.authManager = authManager;
const isAuth = await this.authManager.checkSession();
if (!isAuth || !this.authManager.user?.isAdmin) {
window.location.href = '/login.html';
return;
}
this.setupUserInterface();
} else {
// Fallback if authManager not loaded
window.location.href = '/login.html';
return;
}
this.setupEventListeners();
this.setupFormInteractions();
this.loadCampaigns();
}
setupUserInterface() {
// Add user info to header
const adminHeader = document.querySelector('.admin-header .admin-container');
if (adminHeader && this.authManager.user) {
const userInfo = document.createElement('div');
userInfo.style.cssText = 'position: absolute; top: 1rem; right: 2rem; color: white; font-size: 0.9rem;';
userInfo.innerHTML = `
Welcome, ${this.authManager.user.name || this.authManager.user.email}
<button id="logout-btn" style="margin-left: 1rem; padding: 0.5rem 1rem; background: rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.3); color: white; border-radius: 4px; cursor: pointer;">Logout</button>
`;
adminHeader.style.position = 'relative';
adminHeader.appendChild(userInfo);
// Add logout event listener
document.getElementById('logout-btn').addEventListener('click', () => {
this.authManager.logout();
});
}
}
setupEventListeners() {
}
setupEventListeners() {
// Tab navigation
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
const tab = e.target.dataset.tab;
this.switchTab(tab);
});
});
// Form submissions
document.getElementById('create-campaign-form').addEventListener('submit', (e) => {
this.handleCreateCampaign(e);
});
document.getElementById('edit-campaign-form').addEventListener('submit', (e) => {
this.handleUpdateCampaign(e);
});
// Cancel buttons - using event delegation for proper handling
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="cancel-create"]')) {
this.switchTab('campaigns');
}
if (e.target.matches('[data-action="cancel-edit"]')) {
this.switchTab('campaigns');
}
});
this.loadCampaigns();
}
setupFormInteractions() {
// Create campaign button
const createBtn = document.querySelector('[data-action="create-campaign"]');
if (createBtn) {
createBtn.addEventListener('click', () => this.switchTab('create'));
}
// Cancel buttons
const cancelCreateBtn = document.querySelector('[data-action="cancel-create"]');
if (cancelCreateBtn) {
cancelCreateBtn.addEventListener('click', () => this.switchTab('campaigns'));
}
const cancelEditBtn = document.querySelector('[data-action="cancel-edit"]');
if (cancelEditBtn) {
cancelEditBtn.addEventListener('click', () => this.switchTab('campaigns'));
}
// Handle checkbox changes for government levels
document.querySelectorAll('input[name="target_government_levels"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.updateGovernmentLevelsPreview();
});
});
// Handle settings toggles
document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
checkbox.addEventListener('change', () => {
this.handleSettingsChange(checkbox);
});
});
}
switchTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
// Remove active class from nav buttons
document.querySelectorAll('.nav-btn').forEach(btn => {
btn.classList.remove('active');
});
// Show selected tab
const targetTab = document.getElementById(`${tabName}-tab`);
if (targetTab) {
targetTab.classList.add('active');
}
// Update nav button
const targetNavBtn = document.querySelector(`[data-tab="${tabName}"]`);
if (targetNavBtn) {
targetNavBtn.classList.add('active');
}
// Special handling for different tabs
if (tabName === 'campaigns') {
this.loadCampaigns();
} else if (tabName === 'edit' && this.currentCampaign) {
this.populateEditForm();
}
}
async loadCampaigns() {
const loadingDiv = document.getElementById('campaigns-loading');
const listDiv = document.getElementById('campaigns-list');
loadingDiv.classList.remove('hidden');
listDiv.innerHTML = '';
try {
const response = await window.apiClient.get('/admin/campaigns');
if (response.success) {
this.campaigns = response.campaigns;
this.renderCampaignList();
} else {
throw new Error(response.error || 'Failed to load campaigns');
}
} catch (error) {
console.error('Load campaigns error:', error);
this.showMessage('Failed to load campaigns: ' + error.message, 'error');
} finally {
loadingDiv.classList.add('hidden');
}
}
renderCampaignList() {
const listDiv = document.getElementById('campaigns-list');
if (this.campaigns.length === 0) {
listDiv.innerHTML = `
<div class="empty-state">
<h3>No campaigns yet</h3>
<p>Create your first campaign to get started.</p>
</div>
`;
return;
}
listDiv.innerHTML = this.campaigns.map(campaign => `
<div class="campaign-card" data-campaign-id="${campaign.id}">
<div class="campaign-header">
<h3>${this.escapeHtml(campaign.title)}</h3>
<span class="status-badge status-${campaign.status}">${campaign.status}</span>
</div>
<div class="campaign-meta">
<p><strong>Slug:</strong> <code>/campaign/${campaign.slug}</code></p>
<p><strong>Email Count:</strong> ${campaign.emailCount || 0}</p>
<p><strong>Created:</strong> ${this.formatDate(campaign.created_at)}</p>
</div>
<div class="campaign-actions">
<button class="btn btn-secondary" data-action="edit-campaign" data-campaign-id="${campaign.id}">
Edit
</button>
<button class="btn btn-secondary" data-action="view-analytics" data-campaign-id="${campaign.id}">
Analytics
</button>
<a href="/campaign/${campaign.slug}" target="_blank" class="btn btn-secondary">
View Public Page
</a>
<button class="btn btn-danger" data-action="delete-campaign" data-campaign-id="${campaign.id}">
Delete
</button>
</div>
</div>
`).join('');
// Attach event listeners to campaign actions
this.attachCampaignActionListeners();
}
attachCampaignActionListeners() {
// Edit campaign buttons
document.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.editCampaign(campaignId);
});
});
// Delete campaign buttons
document.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.deleteCampaign(campaignId);
});
});
// Analytics buttons
document.querySelectorAll('[data-action="view-analytics"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const campaignId = parseInt(e.target.dataset.campaignId);
this.viewAnalytics(campaignId);
});
});
}
async handleCreateCampaign(e) {
e.preventDefault();
const formData = new FormData(e.target);
const campaignData = {
title: formData.get('title'),
description: formData.get('description'),
email_subject: formData.get('email_subject'),
email_body: formData.get('email_body'),
call_to_action: formData.get('call_to_action'),
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',
show_email_count: formData.get('show_email_count') === 'on',
target_government_levels: Array.from(formData.getAll('target_government_levels'))
};
try {
const response = await window.apiClient.post('/admin/campaigns', campaignData);
if (response.success) {
this.showMessage('Campaign created successfully!', 'success');
e.target.reset();
this.switchTab('campaigns');
} else {
throw new Error(response.error || 'Failed to create campaign');
}
} catch (error) {
console.error('Create campaign error:', error);
this.showMessage('Failed to create campaign: ' + error.message, 'error');
}
}
editCampaign(campaignId) {
this.currentCampaign = this.campaigns.find(c => c.id === campaignId);
if (this.currentCampaign) {
this.switchTab('edit');
}
}
populateEditForm() {
if (!this.currentCampaign) return;
const form = document.getElementById('edit-campaign-form');
const campaign = this.currentCampaign;
// Populate form fields
form.querySelector('[name="title"]').value = campaign.title || '';
form.querySelector('[name="description"]').value = campaign.description || '';
form.querySelector('[name="email_subject"]').value = campaign.email_subject || '';
form.querySelector('[name="email_body"]').value = campaign.email_body || '';
form.querySelector('[name="call_to_action"]').value = campaign.call_to_action || '';
// Status select
form.querySelector('[name="status"]').value = campaign.status || 'draft';
// Checkboxes
form.querySelector('[name="allow_smtp_email"]').checked = campaign.allow_smtp_email;
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;
// Government levels
const targetLevels = campaign.target_government_levels ?
campaign.target_government_levels.split(',').map(l => l.trim()) : [];
form.querySelectorAll('[name="target_government_levels"]').forEach(checkbox => {
checkbox.checked = targetLevels.includes(checkbox.value);
});
}
async handleUpdateCampaign(e) {
e.preventDefault();
if (!this.currentCampaign) return;
const formData = new FormData(e.target);
const updates = {
title: formData.get('title'),
description: formData.get('description'),
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',
show_email_count: formData.get('show_email_count') === 'on',
target_government_levels: Array.from(formData.getAll('target_government_levels'))
};
try {
const response = await window.apiClient.makeRequest(`/admin/campaigns/${this.currentCampaign.id}`, {
method: 'PUT',
body: JSON.stringify(updates)
});
if (response.success) {
this.showMessage('Campaign updated successfully!', 'success');
this.switchTab('campaigns');
} else {
throw new Error(response.error || 'Failed to update campaign');
}
} catch (error) {
console.error('Update campaign error:', error);
this.showMessage('Failed to update campaign: ' + error.message, 'error');
}
}
async deleteCampaign(campaignId) {
const campaign = this.campaigns.find(c => c.id === campaignId);
if (!campaign) return;
if (!confirm(`Are you sure you want to delete the campaign "${campaign.title}"? This action cannot be undone.`)) {
return;
}
try {
const response = await window.apiClient.makeRequest(`/admin/campaigns/${campaignId}`, {
method: 'DELETE'
});
if (response.success) {
this.showMessage('Campaign deleted successfully!', 'success');
this.loadCampaigns();
} else {
throw new Error(response.error || 'Failed to delete campaign');
}
} catch (error) {
console.error('Delete campaign error:', error);
this.showMessage('Failed to delete campaign: ' + error.message, 'error');
}
}
async viewAnalytics(campaignId) {
try {
const response = await window.apiClient.get(`/admin/campaigns/${campaignId}/analytics`);
if (response.success) {
this.showAnalyticsModal(response.analytics);
} else {
throw new Error(response.error || 'Failed to load analytics');
}
} catch (error) {
console.error('Analytics error:', error);
this.showMessage('Failed to load analytics: ' + error.message, 'error');
}
}
showAnalyticsModal(analytics) {
// Create a simple analytics modal
const modal = document.createElement('div');
modal.className = 'modal-overlay';
modal.innerHTML = `
<div class="modal-content" style="max-width: 800px;">
<div class="modal-header">
<h2>Campaign Analytics</h2>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<div class="analytics-grid">
<div class="analytics-stat">
<h3>${analytics.totalEmails}</h3>
<p>Total Emails</p>
</div>
<div class="analytics-stat">
<h3>${analytics.smtpEmails}</h3>
<p>SMTP Emails</p>
</div>
<div class="analytics-stat">
<h3>${analytics.mailtoClicks}</h3>
<p>Mailto Clicks</p>
</div>
<div class="analytics-stat">
<h3>${analytics.successfulEmails}</h3>
<p>Successful</p>
</div>
</div>
${Object.keys(analytics.byLevel).length > 0 ? `
<h3>By Government Level</h3>
<div class="level-stats">
${Object.entries(analytics.byLevel).map(([level, count]) =>
`<div class="level-stat"><strong>${level}:</strong> ${count}</div>`
).join('')}
</div>
` : ''}
${analytics.recentEmails.length > 0 ? `
<h3>Recent Activity</h3>
<div class="recent-activity">
${analytics.recentEmails.slice(0, 5).map(email => `
<div class="activity-item">
<strong>${email.user_name || 'Anonymous'}</strong>
${email.recipient_name} (${email.recipient_level})
<span class="timestamp">${this.formatDate(email.timestamp)}</span>
</div>
`).join('')}
</div>
` : ''}
</div>
</div>
`;
document.body.appendChild(modal);
// Close modal handlers
modal.querySelector('.modal-close').addEventListener('click', () => {
document.body.removeChild(modal);
});
modal.addEventListener('click', (e) => {
if (e.target === modal) {
document.body.removeChild(modal);
}
});
}
updateGovernmentLevelsPreview() {
const checkboxes = document.querySelectorAll('input[name="target_government_levels"]:checked');
const levels = Array.from(checkboxes).map(cb => cb.value);
// Could update a preview somewhere if needed
console.log('Selected government levels:', levels);
}
handleSettingsChange(checkbox) {
// Handle real-time settings changes if needed
console.log(`Setting ${checkbox.name} changed to:`, checkbox.checked);
}
showMessage(message, type = 'info') {
const container = document.getElementById('message-container');
container.className = `message-${type}`;
container.textContent = message;
container.classList.remove('hidden');
setTimeout(() => {
container.classList.add('hidden');
}, 5000);
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatDate(dateString) {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-CA', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch (error) {
return dateString;
}
}
}
// Initialize admin panel when DOM is loaded
document.addEventListener('DOMContentLoaded', async () => {
window.adminPanel = new AdminPanel();
await window.adminPanel.init();
});

View File

@@ -0,0 +1,73 @@
// API Client for making requests to the backend
class APIClient {
constructor() {
this.baseURL = '/api';
}
async makeRequest(endpoint, options = {}) {
const config = {
headers: {
'Content-Type': 'application/json',
...options.headers
},
...options
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || data.message || `HTTP ${response.status}`);
}
return data;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
async get(endpoint) {
return this.makeRequest(endpoint, {
method: 'GET'
});
}
async post(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'POST',
body: JSON.stringify(data)
});
}
// Health check
async checkHealth() {
return this.get('/health');
}
// Test Represent API connection
async testRepresent() {
return this.get('/test-represent');
}
// Get representatives by postal code
async getRepresentativesByPostalCode(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.get(`/representatives/by-postal/${cleanPostalCode}`);
}
// Refresh representatives for postal code
async refreshRepresentatives(postalCode) {
const cleanPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
return this.post(`/representatives/refresh-postal/${cleanPostalCode}`);
}
// Send email to representative
async sendEmail(emailData) {
return this.post('/emails/send', emailData);
}
}
// Create global instance
window.apiClient = new APIClient();

View File

@@ -0,0 +1,196 @@
// Authentication module for handling login/logout and session management
class AuthManager {
constructor() {
this.user = null;
this.isAuthenticated = false;
}
// Initialize authentication state
async init() {
await this.checkSession();
this.setupAuthListeners();
}
// Check current session status
async checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return true;
} else {
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
} catch (error) {
console.error('Session check failed:', error);
this.isAuthenticated = false;
this.user = null;
this.updateUI();
return false;
}
}
// Login with email and password
async login(email, password) {
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
this.isAuthenticated = true;
this.user = response.user;
this.updateUI();
return { success: true };
} else {
return { success: false, error: response.error };
}
} catch (error) {
console.error('Login error:', error);
return { success: false, error: error.message || 'Login failed' };
}
}
// Logout current user
async logout() {
try {
await apiClient.post('/auth/logout');
this.isAuthenticated = false;
this.user = null;
this.updateUI();
// Redirect to login page
window.location.href = '/login.html';
} catch (error) {
console.error('Logout error:', error);
// Force logout on client side even if server request fails
this.isAuthenticated = false;
this.user = null;
this.updateUI();
window.location.href = '/login.html';
}
}
// Update UI based on authentication state
updateUI() {
// Update user info display
const userInfo = document.getElementById('user-info');
if (userInfo) {
if (this.isAuthenticated && this.user) {
userInfo.innerHTML = `
<span>Welcome, ${this.user.name || this.user.email}</span>
<button id="logout-btn" class="btn btn-secondary">Logout</button>
`;
// Add logout button listener
const logoutBtn = document.getElementById('logout-btn');
if (logoutBtn) {
logoutBtn.addEventListener('click', () => this.logout());
}
} else {
userInfo.innerHTML = '';
}
}
// Show/hide admin elements
const adminElements = document.querySelectorAll('.admin-only');
adminElements.forEach(element => {
if (this.isAuthenticated && this.user?.isAdmin) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
// Show/hide authenticated elements
const authElements = document.querySelectorAll('.auth-only');
authElements.forEach(element => {
if (this.isAuthenticated) {
element.style.display = 'block';
} else {
element.style.display = 'none';
}
});
}
// Set up event listeners for auth-related actions
setupAuthListeners() {
// Global logout button
document.addEventListener('click', (e) => {
if (e.target.matches('[data-action="logout"]')) {
e.preventDefault();
this.logout();
}
});
// Login form submission
const loginForm = document.getElementById('login-form');
if (loginForm) {
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
const result = await this.login(email, password);
if (result.success) {
// Redirect to admin panel
window.location.href = '/admin.html';
} else {
// Show error message
const errorElement = document.getElementById('error-message');
if (errorElement) {
errorElement.textContent = result.error;
errorElement.style.display = 'block';
}
}
});
}
}
// Require authentication for current page
requireAuth() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
return true;
}
// Require admin access for current page
requireAdmin() {
if (!this.isAuthenticated) {
window.location.href = '/login.html';
return false;
}
if (!this.user?.isAdmin) {
alert('Admin access required');
window.location.href = '/';
return false;
}
return true;
}
}
// Create global auth manager instance
const authManager = new AuthManager();
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', () => {
authManager.init();
});
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = AuthManager;
}

View File

@@ -0,0 +1,237 @@
// Email Composer Module
class EmailComposer {
constructor() {
this.modal = document.getElementById('email-modal');
this.form = document.getElementById('email-form');
this.closeBtn = document.getElementById('close-modal');
this.cancelBtn = document.getElementById('cancel-email');
this.messageTextarea = document.getElementById('email-message');
this.charCounter = document.querySelector('.char-counter');
this.currentRecipient = null;
this.init();
}
init() {
// Modal controls
this.closeBtn.addEventListener('click', () => this.closeModal());
this.cancelBtn.addEventListener('click', () => this.closeModal());
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.closeModal();
});
// Form handling
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Character counter
this.messageTextarea.addEventListener('input', () => this.updateCharCounter());
// Escape key to close modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modal.style.display === 'block') {
this.closeModal();
}
});
}
openModal(recipient) {
this.currentRecipient = recipient;
// Populate recipient info
document.getElementById('recipient-email').value = recipient.email;
document.getElementById('recipient-info').innerHTML = `
<strong>${recipient.name}</strong><br>
${recipient.office}<br>
${recipient.district}<br>
<em>${recipient.email}</em>
`;
// Set postal code from current lookup
const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : '';
document.getElementById('sender-postal-code').value = postalCode;
// Clear form fields
document.getElementById('sender-name').value = '';
document.getElementById('sender-email').value = '';
document.getElementById('email-subject').value = '';
document.getElementById('email-message').value = '';
// Set default subject
document.getElementById('email-subject').value = `Message from your constituent in ${postalCode}`;
this.updateCharCounter();
this.modal.style.display = 'block';
// Focus on first input
document.getElementById('sender-name').focus();
}
closeModal() {
this.modal.style.display = 'none';
this.currentRecipient = null;
}
updateCharCounter() {
const maxLength = 5000;
const currentLength = this.messageTextarea.value.length;
const remaining = maxLength - currentLength;
this.charCounter.textContent = `${remaining} characters remaining`;
if (remaining < 100) {
this.charCounter.style.color = '#dc3545'; // Red
} else if (remaining < 500) {
this.charCounter.style.color = '#ffc107'; // Yellow
} else {
this.charCounter.style.color = '#666'; // Default
}
}
validateForm() {
const errors = [];
const senderName = document.getElementById('sender-name').value.trim();
const senderEmail = document.getElementById('sender-email').value.trim();
const subject = document.getElementById('email-subject').value.trim();
const message = document.getElementById('email-message').value.trim();
if (!senderName) {
errors.push('Your name is required');
}
if (!senderEmail) {
errors.push('Your email is required');
} else if (!this.validateEmail(senderEmail)) {
errors.push('Please enter a valid email address');
}
if (!subject) {
errors.push('Subject is required');
}
if (!message) {
errors.push('Message is required');
} else if (message.length < 10) {
errors.push('Message must be at least 10 characters long');
}
// Check for suspicious content
if (this.containsSuspiciousContent(message) || this.containsSuspiciousContent(subject)) {
errors.push('Your message contains content that may not be appropriate');
}
return errors;
}
validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
containsSuspiciousContent(text) {
const suspiciousPatterns = [
/<script/i,
/javascript:/i,
/on\w+\s*=/i,
/<iframe/i,
/<object/i,
/<embed/i
];
return suspiciousPatterns.some(pattern => pattern.test(text));
}
async handleSubmit(e) {
e.preventDefault();
const errors = this.validateForm();
if (errors.length > 0) {
window.messageDisplay.show(errors.join('<br>'), 'error');
return;
}
const submitButton = this.form.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
try {
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
const emailData = {
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
};
const result = await window.apiClient.sendEmail(emailData);
if (result.success) {
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
this.closeModal();
} else {
throw new Error(result.message || 'Failed to send email');
}
} catch (error) {
console.error('Email send failed:', error);
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
// Helper method to get template messages
getTemplateMessage(type) {
const templates = {
general: `Dear {{name}},
I am writing as your constituent from {{postalCode}} to express my views on an important matter.
[Please write your message here]
I would appreciate your response on this issue and would like to know your position.
Thank you for your time and service to our community.
Sincerely,
{{senderName}}`,
concern: `Dear {{name}},
I am writing to express my concern about [specific issue] as your constituent from {{postalCode}}.
[Describe your concern and its impact]
I urge you to [specific action you want them to take].
Thank you for considering my views on this important matter.
Best regards,
{{senderName}}`,
support: `Dear {{name}},
I am writing to express my support for [specific issue/bill/policy] as your constituent from {{postalCode}}.
[Explain why you support this and its importance]
I encourage you to continue supporting this initiative.
Thank you for your leadership on this matter.
Respectfully,
{{senderName}}`
};
return templates[type] || templates.general;
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.emailComposer = new EmailComposer();
});

View File

@@ -0,0 +1,79 @@
// Login page specific functionality
document.addEventListener('DOMContentLoaded', function() {
const loginForm = document.getElementById('login-form');
const loginBtn = document.getElementById('login-btn');
const loginText = document.getElementById('login-text');
const loading = document.querySelector('.loading');
const errorMessage = document.getElementById('error-message');
// Check if already logged in
checkSession();
loginForm.addEventListener('submit', async function(e) {
e.preventDefault();
const email = document.getElementById('email').value.trim();
const password = document.getElementById('password').value;
if (!email || !password) {
showError('Please enter both email and password');
return;
}
setLoading(true);
hideError();
try {
const response = await apiClient.post('/auth/login', {
email,
password
});
if (response.success) {
// Redirect to admin panel
window.location.href = '/admin.html';
} else {
showError(response.error || 'Login failed');
}
} catch (error) {
console.error('Login error:', error);
showError(error.message || 'Login failed. Please try again.');
} finally {
setLoading(false);
}
});
async function checkSession() {
try {
const response = await apiClient.get('/auth/session');
if (response.authenticated) {
// Already logged in, redirect to admin
window.location.href = '/admin.html';
}
} catch (error) {
// Not logged in, continue with login form
console.log('Not logged in');
}
}
function setLoading(isLoading) {
loginBtn.disabled = isLoading;
loginText.style.display = isLoading ? 'none' : 'inline';
loading.style.display = isLoading ? 'block' : 'none';
}
function showError(message) {
errorMessage.textContent = message;
errorMessage.style.display = 'block';
}
function hideError() {
errorMessage.style.display = 'none';
}
// Check for URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('expired') === 'true') {
showError('Your session has expired. Please log in again.');
}
});

View File

@@ -0,0 +1,152 @@
// Main Application Module
class MainApp {
constructor() {
this.init();
}
init() {
// Initialize message display system
window.messageDisplay = new MessageDisplay();
// Check API health on startup
this.checkAPIHealth();
// 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');
});
// 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();
});
}
async checkAPIHealth() {
try {
await window.apiClient.checkHealth();
console.log('API health check passed');
} catch (error) {
console.error('API health check failed:', error);
window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error');
}
}
}
// Message Display System
class MessageDisplay {
constructor() {
this.container = document.getElementById('message-display');
this.timeouts = new Map();
}
show(message, type = 'info', duration = 5000) {
// Clear existing timeout for this container
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
}
// Set message content and type
this.container.innerHTML = message;
this.container.className = `message-display ${type}`;
this.container.style.display = 'block';
// Auto-hide after duration
const timeout = setTimeout(() => {
this.hide();
}, duration);
this.timeouts.set(this.container, timeout);
// Add click to dismiss
this.container.style.cursor = 'pointer';
this.container.onclick = () => this.hide();
}
hide() {
this.container.style.display = 'none';
this.container.onclick = null;
// Clear timeout
if (this.timeouts.has(this.container)) {
clearTimeout(this.timeouts.get(this.container));
this.timeouts.delete(this.container);
}
}
}
// Utility functions
const Utils = {
// Format postal code consistently
formatPostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
if (cleaned.length === 6) {
return `${cleaned.slice(0, 3)} ${cleaned.slice(3)}`;
}
return cleaned;
},
// Sanitize text input
sanitizeText(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
// Debounce function for input handling
debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
},
// Check if we're on mobile
isMobile() {
return window.innerWidth <= 768;
},
// Format date for display
formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleDateString('en-CA', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
};
// Make utils globally available
window.Utils = Utils;
// Initialize app when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.mainApp = new MainApp();
// Add some basic accessibility improvements
document.addEventListener('keydown', (e) => {
// Allow Escape to close modals (handled in individual modules)
// Add tab navigation improvements if needed
});
// Add responsive behavior
window.addEventListener('resize', Utils.debounce(() => {
// Handle responsive layout changes if needed
const isMobile = Utils.isMobile();
document.body.classList.toggle('mobile', isMobile);
}, 250));
// Initial mobile class
document.body.classList.toggle('mobile', Utils.isMobile());
});

View File

@@ -0,0 +1,158 @@
// Postal Code Lookup Module
class PostalLookup {
constructor() {
this.form = document.getElementById('postal-form');
this.input = document.getElementById('postal-code');
this.refreshBtn = document.getElementById('refresh-btn');
this.loadingDiv = document.getElementById('loading');
this.errorDiv = document.getElementById('error-message');
this.representativesSection = document.getElementById('representatives-section');
this.locationDetails = document.getElementById('location-details');
this.currentPostalCode = null;
this.init();
}
init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
this.input.addEventListener('input', (e) => this.formatPostalCode(e));
}
formatPostalCode(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;
}
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 };
}
showError(message) {
this.errorDiv.textContent = message;
this.errorDiv.style.display = 'block';
this.representativesSection.style.display = 'none';
}
hideError() {
this.errorDiv.style.display = 'none';
}
showLoading() {
this.loadingDiv.style.display = 'block';
this.hideError();
this.representativesSection.style.display = 'none';
}
hideLoading() {
this.loadingDiv.style.display = 'none';
}
async handleSubmit(e) {
e.preventDefault();
const postalCode = this.input.value.trim();
if (!postalCode) {
this.showError('Please enter a postal code');
return;
}
const validation = this.validatePostalCode(postalCode);
if (!validation.valid) {
this.showError(validation.message);
return;
}
await this.lookupRepresentatives(postalCode);
}
async handleRefresh() {
if (!this.currentPostalCode) return;
try {
this.showLoading();
this.refreshBtn.disabled = true;
const data = await window.apiClient.refreshRepresentatives(this.currentPostalCode);
this.displayResults(data);
window.messageDisplay.show('Representatives data refreshed successfully!', 'success');
} catch (error) {
console.error('Refresh failed:', error);
this.showError(`Failed to refresh data: ${error.message}`);
} finally {
this.hideLoading();
this.refreshBtn.disabled = false;
}
}
async lookupRepresentatives(postalCode) {
try {
this.showLoading();
const data = await window.apiClient.getRepresentativesByPostalCode(postalCode);
this.currentPostalCode = postalCode;
this.displayResults(data);
} catch (error) {
console.error('Lookup failed:', error);
this.showError(`Failed to find representatives: ${error.message}`);
} finally {
this.hideLoading();
}
}
displayResults(apiResponse) {
this.hideError();
this.hideLoading();
// Handle the API response structure
const data = apiResponse.data || apiResponse; // Handle both new and old response formats
// Update location info
let locationText = `Postal Code: ${data.postalCode}`;
if (data.location && data.location.city && data.location.province) {
locationText += `${data.location.city}, ${data.location.province}`;
} else if (data.city && data.province) {
locationText += `${data.city}, ${data.province}`;
}
if (data.source || apiResponse.source) {
locationText += ` • Data source: ${data.source || apiResponse.source}`;
}
locationText += ` • Data source: api`;
this.locationDetails.textContent = locationText;
// Show representatives
const representatives = data.representatives || [];
console.log('Displaying representatives:', representatives.length, representatives);
window.representativesDisplay.displayRepresentatives(representatives);
// Show section and refresh button
this.representativesSection.style.display = 'block';
this.refreshBtn.style.display = 'inline-block';
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.postalLookup = new PostalLookup();
});

View File

@@ -0,0 +1,192 @@
// 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;
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>';
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}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
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>` : ''}
</div>
<div class="rep-actions">
${emailButton}
${profileUrl}
</div>
</div>
</div>
`;
}
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
});
});
});
}
}
// Initialize when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
window.representativesDisplay = new RepresentativesDisplay();
});