tonne of imporvements, debugs, new ui

This commit is contained in:
admin 2025-09-20 15:58:55 -06:00
parent f93765f38b
commit cd1099c428
13 changed files with 673 additions and 87 deletions

View File

@ -11,7 +11,7 @@ class CampaignsController {
// Get email counts for each campaign and normalize data structure // Get email counts for each campaign and normalize data structure
const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => { const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => {
const id = campaign.Id ?? campaign.id; const id = campaign.ID || campaign.Id || campaign.id;
let emailCount = 0; let emailCount = 0;
if (id != null) { if (id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id); emailCount = await nocoDB.getCampaignEmailCount(id);
@ -66,7 +66,13 @@ class CampaignsController {
}); });
} }
const normalizedId = campaign.Id ?? campaign.id ?? id; // Debug logging
console.log('Campaign object keys:', Object.keys(campaign));
console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id);
const normalizedId = campaign.ID || campaign.Id || campaign.id;
console.log('Using normalized ID:', normalizedId);
const emailCount = await nocoDB.getCampaignEmailCount(normalizedId); const emailCount = await nocoDB.getCampaignEmailCount(normalizedId);
// Normalize campaign data structure for frontend // Normalize campaign data structure for frontend
@ -136,7 +142,7 @@ class CampaignsController {
res.json({ res.json({
success: true, success: true,
campaign: { campaign: {
id: campaign.Id || campaign.id, id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug, slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title, title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description, description: campaign['Description'] || campaign.description,
@ -219,7 +225,7 @@ class CampaignsController {
res.status(201).json({ res.status(201).json({
success: true, success: true,
campaign: { campaign: {
id: campaign.Id ?? campaign.id, id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug, slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title, title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description, description: campaign['Description'] || campaign.description,
@ -259,7 +265,7 @@ class CampaignsController {
// Ensure slug is unique (but allow current campaign to keep its slug) // Ensure slug is unique (but allow current campaign to keep its slug)
const existingCampaign = await nocoDB.getCampaignBySlug(slug); const existingCampaign = await nocoDB.getCampaignBySlug(slug);
const existingId = existingCampaign ? (existingCampaign.Id || existingCampaign.id) : null; const existingId = existingCampaign ? (existingCampaign.ID || existingCampaign.Id || existingCampaign.id) : null;
if (existingCampaign && String(existingId) !== String(id)) { if (existingCampaign && String(existingId) !== String(id)) {
let counter = 1; let counter = 1;
let originalSlug = slug; let originalSlug = slug;
@ -288,7 +294,7 @@ class CampaignsController {
res.json({ res.json({
success: true, success: true,
campaign: { campaign: {
id: campaign.Id ?? campaign.id ?? id, id: campaign.ID || campaign.Id || campaign.id,
slug: campaign['Campaign Slug'] || campaign.slug, slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title, title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description, description: campaign['Description'] || campaign.description,
@ -362,7 +368,9 @@ class CampaignsController {
}); });
} }
if (campaign.status !== 'active') {
if ((campaign['Status'] || campaign.status) !== 'active') {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: 'Campaign is not currently active' error: 'Campaign is not currently active'
@ -370,22 +378,22 @@ class CampaignsController {
} }
// Check if the requested email method is allowed // Check if the requested email method is allowed
if (emailMethod === 'smtp' && !campaign.allow_smtp_email) { if (emailMethod === 'smtp' && !(campaign['Allow SMTP Email'] || campaign.allow_smtp_email)) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: 'SMTP email sending is not enabled for this campaign' error: 'SMTP email sending is not enabled for this campaign'
}); });
} }
if (emailMethod === 'mailto' && !campaign.allow_mailto_link) { if (emailMethod === 'mailto' && !(campaign['Allow Mailto Link'] || campaign.allow_mailto_link)) {
return res.status(403).json({ return res.status(403).json({
success: false, success: false,
error: 'Mailto links are not enabled for this campaign' error: 'Mailto links are not enabled for this campaign'
}); });
} }
const subject = campaign.email_subject; const subject = campaign['Email Subject'] || campaign.email_subject;
const message = campaign.email_body; const message = campaign['Email Body'] || campaign.email_body;
let emailResult = { success: true }; let emailResult = { success: true };
@ -403,14 +411,14 @@ class CampaignsController {
html: ` html: `
<p>${message.replace(/\n/g, '<br>')}</p> <p>${message.replace(/\n/g, '<br>')}</p>
<hr> <hr>
<p><small>This message was sent via the Alberta Influence Campaign Tool by ${userName || 'A constituent'} (${userEmail}) from postal code ${postalCode} as part of the "${campaign.title}" campaign.</small></p> <p><small>This message was sent via the BNKops Influence Campaign Tool by ${userName || 'A constituent'} (${userEmail}) from postal code ${postalCode} as part of the "${campaign['Campaign Title'] || campaign.title}" campaign.</small></p>
` `
}); });
} }
// Log the campaign email // Log the campaign email
await nocoDB.logCampaignEmail({ await nocoDB.logCampaignEmail({
campaign_id: campaign.Id ?? campaign.id, campaign_id: campaign.ID || campaign.Id || campaign.id,
campaign_slug: slug, campaign_slug: slug,
user_email: userEmail, user_email: userEmail,
user_name: userName, user_name: userName,
@ -422,8 +430,7 @@ class CampaignsController {
email_method: emailMethod, email_method: emailMethod,
subject: subject, subject: subject,
message: message, message: message,
status: emailMethod === 'mailto' ? 'clicked' : (emailResult.success ? 'sent' : 'failed'), status: emailMethod === 'mailto' ? 'clicked' : (emailResult.success ? 'sent' : 'failed')
timestamp: new Date().toISOString()
}); });
if (emailMethod === 'smtp') { if (emailMethod === 'smtp') {
@ -458,6 +465,62 @@ class CampaignsController {
} }
} }
// Track user info when they find representatives
async trackUserInfo(req, res, next) {
try {
const { slug } = req.params;
const { userEmail, userName, postalCode } = req.body;
// Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = campaign['Status'] || campaign.status;
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Log user interaction - finding representatives
await nocoDB.logCampaignEmail({
campaign_id: campaign.ID || campaign.Id || campaign.id,
campaign_slug: slug,
user_email: userEmail || '',
user_name: userName || '',
user_postal_code: postalCode,
recipient_email: '',
recipient_name: '',
recipient_title: '',
recipient_level: 'Other',
email_method: 'smtp', // Use valid option but distinguish by status
subject: 'User Info Capture',
message: 'User searched for representatives',
status: 'user_info_captured'
});
res.json({
success: true,
message: 'User info tracked successfully'
});
} catch (error) {
console.error('Track user info error:', error);
res.status(500).json({
success: false,
error: 'Failed to track user info',
message: error.message,
details: error.response?.data || null
});
}
}
// Get representatives for postal code (for campaign use) // Get representatives for postal code (for campaign use)
async getRepresentativesForCampaign(req, res, next) { async getRepresentativesForCampaign(req, res, next) {
try { try {

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - Alberta Influence Campaign Tool</title> <title>Admin Panel - BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css"> <link rel="stylesheet" href="css/styles.css">
<style> <style>
@ -286,6 +286,187 @@
cursor: pointer; cursor: pointer;
} }
/* Enhanced Form Styling */
.form-group label {
display: block;
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e0e6ed;
border-radius: 8px;
font-size: 0.95rem;
transition: border-color 0.3s, box-shadow 0.3s;
background: #f8f9fa;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
background: white;
}
/* Enhanced Status Select */
.status-select {
position: relative;
}
.status-select select {
appearance: none;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-weight: 600;
padding: 1rem 3rem 1rem 1rem;
font-size: 1rem;
border: none;
cursor: pointer;
text-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.status-select select option {
background: white;
color: #2c3e50;
font-weight: 500;
padding: 0.75rem;
text-shadow: none;
}
.status-select select option:hover,
.status-select select option:checked {
background: #3498db;
color: white;
}
.status-select::after {
content: '▼';
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
color: white;
pointer-events: none;
font-size: 0.8rem;
}
.status-select select:focus {
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.3);
}
/* Enhanced Checkbox Styling */
.checkbox-group {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 12px;
border: 2px solid #e9ecef;
margin-top: 0.5rem;
}
.checkbox-item {
background: white;
padding: 1rem;
border-radius: 8px;
border: 2px solid #e9ecef;
margin-bottom: 0.75rem;
transition: all 0.3s;
cursor: pointer;
}
.checkbox-item:hover {
border-color: #3498db;
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(52, 152, 219, 0.15);
}
.checkbox-item input[type="checkbox"] {
width: auto;
margin-right: 0.75rem;
transform: scale(1.2);
accent-color: #3498db;
}
.checkbox-item label {
margin: 0;
font-weight: 500;
color: #495057;
cursor: pointer;
flex: 1;
}
.checkbox-item:has(input:checked) {
background: linear-gradient(135deg, #e3f2fd 0%, #f3e5f5 100%);
border-color: #3498db;
}
.checkbox-item:has(input:checked) label {
color: #2c3e50;
font-weight: 600;
}
/* Enhanced Form Grid */
.form-grid {
background: white;
padding: 2rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
margin-bottom: 2rem;
}
/* Section Headers */
.section-header {
color: #2c3e50;
font-size: 1.1rem;
font-weight: 600;
margin: 2rem 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #e9ecef;
}
/* Enhanced Buttons */
.btn {
padding: 1rem 2rem;
border: none;
border-radius: 8px;
cursor: pointer;
text-decoration: none;
display: inline-block;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-secondary:hover {
background: #5a6268;
transform: translateY(-1px);
}
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-nav { .admin-nav {
flex-direction: column; flex-direction: column;
@ -324,7 +505,6 @@
<div id="campaigns-tab" class="tab-content active"> <div id="campaigns-tab" class="tab-content active">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;"> <div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">Active Campaigns</h2> <h2 style="margin: 0;">Active Campaigns</h2>
<button class="btn btn-primary" data-action="create-campaign">Create New Campaign</button>
</div> </div>
<div id="campaigns-loading" class="loading hidden"> <div id="campaigns-loading" class="loading hidden">
@ -349,6 +529,16 @@
placeholder="Save Alberta Parks"> placeholder="Save Alberta Parks">
</div> </div>
<div class="form-group status-select">
<label for="create-status">Campaign Status</label>
<select id="create-status" name="status">
<option value="draft">📝 Draft</option>
<option value="active">🚀 Active</option>
<option value="paused">⏸️ Paused</option>
<option value="archived">📦 Archived</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="create-description">Description</label> <label for="create-description">Description</label>
<textarea id="create-description" name="description" rows="3" <textarea id="create-description" name="description" rows="3"
@ -379,46 +569,46 @@ Sincerely,
placeholder="Join thousands of Albertans in protecting our provincial parks. Send an email to your representatives today!"></textarea> placeholder="Join thousands of Albertans in protecting our provincial parks. Send an email to your representatives today!"></textarea>
</div> </div>
<div class="section-header">⚙️ Campaign Settings</div>
<div class="form-group"> <div class="form-group">
<label>Campaign Settings</label>
<div class="checkbox-group"> <div class="checkbox-group">
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-allow-smtp" name="allow_smtp_email" checked> <input type="checkbox" id="create-allow-smtp" name="allow_smtp_email" checked>
<label for="create-allow-smtp">Allow SMTP Email Sending</label> <label for="create-allow-smtp">📧 Allow SMTP Email Sending</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-allow-mailto" name="allow_mailto_link" checked> <input type="checkbox" id="create-allow-mailto" name="allow_mailto_link" checked>
<label for="create-allow-mailto">Allow Mailto Links</label> <label for="create-allow-mailto">🔗 Allow Mailto Links</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-collect-info" name="collect_user_info" checked> <input type="checkbox" id="create-collect-info" name="collect_user_info" checked>
<label for="create-collect-info">Collect User Information</label> <label for="create-collect-info">👤 Collect User Information</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-show-count" name="show_email_count" checked> <input type="checkbox" id="create-show-count" name="show_email_count" checked>
<label for="create-show-count">Show Email Count</label> <label for="create-show-count">📊 Show Email Count</label>
</div> </div>
</div> </div>
</div> </div>
<div class="section-header">🏛️ Target Government Levels</div>
<div class="form-group"> <div class="form-group">
<label>Target Government Levels</label>
<div class="checkbox-group"> <div class="checkbox-group">
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-federal" name="target_government_levels" value="Federal" checked> <input type="checkbox" id="create-federal" name="target_government_levels" value="Federal" checked>
<label for="create-federal">Federal (MPs)</label> <label for="create-federal">🍁 Federal (MPs)</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-provincial" name="target_government_levels" value="Provincial" checked> <input type="checkbox" id="create-provincial" name="target_government_levels" value="Provincial" checked>
<label for="create-provincial">Provincial (MLAs)</label> <label for="create-provincial">🏛️ Provincial (MLAs)</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-municipal" name="target_government_levels" value="Municipal" checked> <input type="checkbox" id="create-municipal" name="target_government_levels" value="Municipal" checked>
<label for="create-municipal">Municipal (Mayors, Councillors)</label> <label for="create-municipal">🏙️ Municipal (Mayors, Councillors)</label>
</div> </div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-school" name="target_government_levels" value="School Board"> <input type="checkbox" id="create-school" name="target_government_levels" value="School Board">
<label for="create-school">School Board</label> <label for="create-school">🎓 School Board</label>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title id="page-title">Campaign - Alberta Influence Tool</title> <title id="page-title">Campaign - BNKops Influence Tool</title>
<link rel="stylesheet" href="/css/styles.css"> <link rel="stylesheet" href="/css/styles.css">
<style> <style>
.campaign-header { .campaign-header {
@ -266,12 +266,12 @@
<div id="optional-fields" style="display: none;"> <div id="optional-fields" style="display: none;">
<div class="form-group"> <div class="form-group">
<label for="user-name">Your Name (Optional)</label> <label for="user-name">Your Name</label>
<input type="text" id="user-name" name="userName" placeholder="Your full name"> <input type="text" id="user-name" name="userName" placeholder="Your full name">
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="user-email">Your Email (Optional)</label> <label for="user-email">Your Email (Optional - If you would like a reply)</label>
<input type="email" id="user-email" name="userEmail" placeholder="your@email.com"> <input type="email" id="user-email" name="userEmail" placeholder="your@email.com">
</div> </div>
</div> </div>

View File

@ -37,6 +37,75 @@ header p {
font-size: 1.1em; font-size: 1.1em;
} }
/* Buttons */
.btn {
display: inline-block;
padding: 10px 16px;
margin: 2px;
font-size: 14px;
font-weight: 500;
line-height: 1.5;
text-align: center;
text-decoration: none;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
border: 1px solid transparent;
border-radius: 4px;
transition: all 0.2s ease-in-out;
user-select: none;
}
.btn:hover {
text-decoration: none;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.btn:active {
transform: translateY(0);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.btn-primary {
color: white;
background-color: #005a9c;
border-color: #005a9c;
}
.btn-primary:hover {
background-color: #004a7c;
border-color: #004a7c;
}
.btn-secondary {
color: #005a9c;
background-color: white;
border-color: #005a9c;
}
.btn-secondary:hover {
color: white;
background-color: #005a9c;
border-color: #005a9c;
}
.btn-success {
color: white;
background-color: #28a745;
border-color: #28a745;
}
.btn-success:hover {
background-color: #218838;
border-color: #1e7e34;
}
.text-muted {
color: #6c757d;
font-style: italic;
}
/* Forms */ /* Forms */
.form-group { .form-group {
margin-bottom: 20px; margin-bottom: 20px;
@ -278,6 +347,17 @@ header p {
flex-wrap: wrap; flex-wrap: wrap;
} }
.call-representative {
display: inline-flex;
align-items: center;
gap: 6px;
}
.call-representative:hover {
background-color: #218838;
border-color: #1e7e34;
}
/* Modal Styles */ /* Modal Styles */
.modal { .modal {
position: fixed; position: fixed;

View File

@ -0,0 +1,5 @@
<!-- Minimal favicon to prevent 404 errors -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<circle cx="16" cy="16" r="14" fill="#007bff"/>
<text x="16" y="20" text-anchor="middle" fill="white" font-family="Arial" font-size="16" font-weight="bold">I</text>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@ -3,14 +3,14 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Alberta Influence Campaign Tool</title> <title>BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css"> <link rel="stylesheet" href="css/styles.css">
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<header> <header>
<h1>Alberta Influence Campaign Tool</h1> <h1>BNKops Influence Tool</h1>
<p>Connect with your elected representatives across all levels of government</p> <p>Connect with your elected representatives across all levels of government</p>
</header> </header>
@ -20,7 +20,7 @@
<h2>Find Your Representatives</h2> <h2>Find Your Representatives</h2>
<form id="postal-form"> <form id="postal-form">
<div class="form-group"> <div class="form-group">
<label for="postal-code">Enter your Alberta postal code:</label> <label for="postal-code">Enter your postal code:</label>
<div class="input-group"> <div class="input-group">
<input <input
type="text" type="text"

View File

@ -49,8 +49,8 @@ class CampaignPage {
renderCampaign() { renderCampaign() {
// Update page title and header // Update page title and header
document.title = `${this.campaign.title} - Alberta Influence Tool`; document.title = `${this.campaign.title} - BNKops Influence Tool`;
document.getElementById('page-title').textContent = `${this.campaign.title} - Alberta Influence Tool`; document.getElementById('page-title').textContent = `${this.campaign.title} - BNKops Influence Tool`;
document.getElementById('campaign-title').textContent = this.campaign.title; document.getElementById('campaign-title').textContent = this.campaign.title;
document.getElementById('campaign-description').textContent = this.campaign.description; document.getElementById('campaign-description').textContent = this.campaign.description;
@ -74,6 +74,15 @@ class CampaignPage {
// Set up email method options // Set up email method options
this.setupEmailMethodOptions(); this.setupEmailMethodOptions();
// Show optional fields if user info collection is enabled
if (this.campaign.collect_user_info) {
const optionalFields = document.getElementById('optional-fields');
if (optionalFields) {
optionalFields.style.display = 'block';
console.log('Showing optional user info fields');
}
}
// Set initial step // Set initial step
this.setStep(1); this.setStep(1);
} }
@ -131,9 +140,38 @@ class CampaignPage {
userEmail: formData.get('userEmail') || '' userEmail: formData.get('userEmail') || ''
}; };
// Track user info when they click "Find My Representatives"
await this.trackUserInfo();
await this.loadRepresentatives(); await this.loadRepresentatives();
} }
async trackUserInfo() {
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
})
});
const data = await response.json();
if (!data.success) {
console.warn('Failed to track user info:', data.error);
// Don't throw error - this is just tracking, shouldn't block the user
}
} catch (error) {
console.warn('Failed to track user info:', error.message);
// Don't throw error - this is just tracking, shouldn't block the user
}
}
async loadRepresentatives() { async loadRepresentatives() {
this.showLoading('Finding your representatives...'); this.showLoading('Finding your representatives...');
@ -180,10 +218,11 @@ class CampaignPage {
<p>${rep.elected_office || 'Representative'}</p> <p>${rep.elected_office || 'Representative'}</p>
<p>${rep.party_name || ''}</p> <p>${rep.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''} ${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
${this.getPhoneNumber(rep) ? `<p>📞 ${this.getPhoneNumber(rep)}</p>` : ''}
</div> </div>
</div> </div>
${rep.email ? `
<div class="rep-actions"> <div class="rep-actions">
${rep.email ? `
<button class="btn btn-primary" data-action="send-email" <button class="btn btn-primary" data-action="send-email"
data-email="${rep.email}" data-email="${rep.email}"
data-name="${rep.name}" data-name="${rep.name}"
@ -191,8 +230,18 @@ class CampaignPage {
data-level="${this.getGovernmentLevel(rep)}"> data-level="${this.getGovernmentLevel(rep)}">
Send Email Send Email
</button> </button>
` : ''}
${this.getPhoneNumber(rep) ? `
<button class="btn btn-success" data-action="call-representative"
data-phone="${this.getPhoneNumber(rep)}"
data-name="${rep.name}"
data-title="${rep.elected_office || ''}"
data-office-type="${this.getPhoneOfficeType(rep)}">
📞 Call
</button>
` : ''}
${!rep.email && !this.getPhoneNumber(rep) ? '<p style="text-align: center; color: #6c757d;">No contact information available</p>' : ''}
</div> </div>
` : '<p style="text-align: center; color: #6c757d;">No email available</p>'}
</div> </div>
`).join(''); `).join('');
@ -214,6 +263,17 @@ class CampaignPage {
}); });
}); });
// Call buttons
document.querySelectorAll('[data-action="call-representative"]').forEach(btn => {
btn.addEventListener('click', (e) => {
const phone = e.target.dataset.phone;
const name = e.target.dataset.name;
const title = e.target.dataset.title;
const officeType = e.target.dataset.officeType;
this.callRepresentative(phone, name, title, officeType);
});
});
// Reload page button // Reload page button
const reloadBtn = document.querySelector('[data-action="reload-page"]'); const reloadBtn = document.querySelector('[data-action="reload-page"]');
if (reloadBtn) { if (reloadBtn) {
@ -232,6 +292,67 @@ class CampaignPage {
return 'Other'; return 'Other';
} }
getPhoneNumber(rep) {
if (!rep.offices || !Array.isArray(rep.offices)) {
return null;
}
// Find the first office with a phone number
const officeWithPhone = rep.offices.find(office => office.tel);
return officeWithPhone ? officeWithPhone.tel : null;
}
getPhoneOfficeType(rep) {
if (!rep.offices || !Array.isArray(rep.offices)) {
return 'office';
}
const officeWithPhone = rep.offices.find(office => office.tel);
return officeWithPhone ? (officeWithPhone.type || 'office') : 'office';
}
callRepresentative(phone, name, title, 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}${title ? ` - ${title}` : ''}${officeInfo}?\n\nPhone: ${phone}`;
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
// Track the call attempt
this.trackCall(phone, name, title, officeType);
}
}
async trackCall(phone, name, title, officeType) {
try {
await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientPhone: phone,
recipientName: name,
recipientTitle: title,
officeType: officeType
})
});
} catch (error) {
console.error('Failed to track call:', error);
}
}
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) { async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) {
const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value; const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value;

View File

@ -107,6 +107,10 @@ class RepresentativesDisplay {
const party = rep.party_name || 'Party not specified'; const party = rep.party_name || 'Party not specified';
const photoUrl = rep.photo_url || null; 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 ? const emailButton = email ?
`<button class="btn btn-primary compose-email" `<button class="btn btn-primary compose-email"
data-email="${email}" data-email="${email}"
@ -117,6 +121,16 @@ class RepresentativesDisplay {
</button>` : </button>` :
'<span class="text-muted">No email available</span>'; '<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>` : '';
const profileUrl = rep.url ? 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>` : '';
@ -155,9 +169,11 @@ class RepresentativesDisplay {
<p><strong>District:</strong> ${district}</p> <p><strong>District:</strong> ${district}</p>
${party !== 'Party not specified' ? `<p><strong>Party:</strong> ${party}</p>` : ''} ${party !== 'Party not specified' ? `<p><strong>Party:</strong> ${party}</p>` : ''}
${email ? `<p><strong>Email:</strong> ${email}</p>` : ''} ${email ? `<p><strong>Email:</strong> ${email}</p>` : ''}
${primaryPhone ? `<p><strong>Phone:</strong> ${primaryPhone.number} ${primaryPhone.type ? `(${primaryPhone.type})` : ''}</p>` : ''}
</div> </div>
<div class="rep-actions"> <div class="rep-actions">
${emailButton} ${emailButton}
${callButton}
${profileUrl} ${profileUrl}
</div> </div>
</div> </div>
@ -165,6 +181,23 @@ class RepresentativesDisplay {
`; `;
} }
extractPhoneNumbers(offices) {
const phoneNumbers = [];
if (Array.isArray(offices)) {
offices.forEach(office => {
if (office.tel) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
return phoneNumbers;
}
attachEventListeners() { attachEventListeners() {
// Add event listeners for compose email buttons // Add event listeners for compose email buttons
const composeButtons = this.container.querySelectorAll('.compose-email'); const composeButtons = this.container.querySelectorAll('.compose-email');
@ -183,6 +216,36 @@ class RepresentativesDisplay {
}); });
}); });
}); });
// 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);
});
});
}
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;
}
} }
} }

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Login - Alberta Influence Campaign Tool</title> <title>Admin Login - BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,"> <link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css"> <link rel="stylesheet" href="css/styles.css">
<style> <style>

View File

@ -77,6 +77,15 @@ router.get('/admin/campaigns/:id/analytics', requireAdmin, rateLimiter.general,
// Campaign endpoints (Public) // Campaign endpoints (Public)
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug); router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign); router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
'/campaigns/:slug/track-user',
rateLimiter.general,
[
body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format')
],
handleValidationErrors,
campaignsController.trackUserInfo
);
router.post( router.post(
'/campaigns/:slug/send-email', '/campaigns/:slug/send-email',
rateLimiter.email, rateLimiter.email,

View File

@ -80,18 +80,13 @@ class NocoDBService {
// Create record // Create record
async create(tableId, data) { async create(tableId, data) {
try { try {
// Clean data to prevent ID conflicts // Clean the data to remove any null values which can cause NocoDB issues
const cleanData = { ...data }; const cleanData = Object.keys(data).reduce((clean, key) => {
delete cleanData.ID; if (data[key] !== null && data[key] !== undefined) {
delete cleanData.id; clean[key] = data[key];
delete cleanData.Id;
// Remove undefined values
Object.keys(cleanData).forEach(key => {
if (cleanData[key] === undefined) {
delete cleanData[key];
} }
}); return clean;
}, {});
const url = this.getTableUrl(tableId); const url = this.getTableUrl(tableId);
const response = await this.client.post(url, cleanData); const response = await this.client.post(url, cleanData);
@ -102,6 +97,26 @@ class NocoDBService {
} }
} }
// Update record
async update(tableId, recordId, data) {
try {
// Clean the data to remove any null values which can cause NocoDB issues
const cleanData = Object.keys(data).reduce((clean, key) => {
if (data[key] !== null && data[key] !== undefined) {
clean[key] = data[key];
}
return clean;
}, {});
const url = `${this.getTableUrl(tableId)}/${recordId}`;
const response = await this.client.patch(url, cleanData);
return response.data;
} catch (error) {
console.error('Error updating record:', error);
throw error;
}
}
async storeRepresentatives(postalCode, representatives) { async storeRepresentatives(postalCode, representatives) {
@ -110,16 +125,16 @@ class NocoDBService {
for (const rep of representatives) { for (const rep of representatives) {
const record = { const record = {
postal_code: postalCode, 'Postal Code': postalCode,
name: rep.name || '', 'Name': rep.name || '',
email: rep.email || '', 'Email': rep.email || '',
district_name: rep.district_name || '', 'District Name': rep.district_name || '',
elected_office: rep.elected_office || '', 'Elected Office': rep.elected_office || '',
party_name: rep.party_name || '', 'Party Name': rep.party_name || '',
representative_set_name: rep.representative_set_name || '', 'Representative Set Name': rep.representative_set_name || '',
url: rep.url || '', 'Profile URL': rep.url || '',
photo_url: rep.photo_url || '', 'Photo URL': rep.photo_url || '',
cached_at: new Date().toISOString() 'Cached At': new Date().toISOString()
}; };
const result = await this.create(this.tableIds.representatives, record); const result = await this.create(this.tableIds.representatives, record);
@ -181,13 +196,13 @@ class NocoDBService {
async logEmailSend(emailData) { async logEmailSend(emailData) {
try { try {
const record = { const record = {
recipient_email: emailData.recipientEmail, 'Recipient Email': emailData.recipientEmail,
sender_name: emailData.senderName, 'Sender Name': emailData.senderName,
sender_email: emailData.senderEmail, 'Sender Email': emailData.senderEmail,
subject: emailData.subject, 'Subject': emailData.subject,
postal_code: emailData.postalCode, 'Postal Code': emailData.postalCode,
status: emailData.status, 'Status': emailData.status,
sent_at: emailData.timestamp 'Sent At': emailData.timestamp
}; };
await this.create(this.tableIds.emails, record); await this.create(this.tableIds.emails, record);
@ -233,7 +248,13 @@ class NocoDBService {
async storePostalCodeInfo(postalCodeData) { async storePostalCodeInfo(postalCodeData) {
try { try {
const response = await this.create(this.tableIds.postalCodes, postalCodeData); // Map fields to NocoDB column titles
const mappedData = {
'Postal Code': postalCodeData.postal_code,
'City': postalCodeData.city,
'Province': postalCodeData.province
};
const response = await this.create(this.tableIds.postalCodes, mappedData);
return response; return response;
} catch (error) { } catch (error) {
// Don't throw error for postal code caching failures // Don't throw error for postal code caching failures
@ -347,7 +368,25 @@ class NocoDBService {
// Campaign email tracking methods // Campaign email tracking methods
async logCampaignEmail(emailData) { async logCampaignEmail(emailData) {
try { try {
const response = await this.create(this.tableIds.campaignEmails, emailData); // Map fields to NocoDB column titles
const mappedData = {
'Campaign ID': emailData.campaign_id,
'Campaign Slug': emailData.campaign_slug,
'User Email': emailData.user_email,
'User Name': emailData.user_name,
'User Postal Code': emailData.user_postal_code,
'Recipient Email': emailData.recipient_email,
'Recipient Name': emailData.recipient_name,
'Recipient Title': emailData.recipient_title,
'Government Level': emailData.recipient_level,
'Email Method': emailData.email_method,
'Subject': emailData.subject,
'Message': emailData.message,
'Status': emailData.status
// Note: 'Sent At' has default value of now() so we don't need to set it
};
const response = await this.create(this.tableIds.campaignEmails, mappedData);
return response; return response;
} catch (error) { } catch (error) {
console.error('Log campaign email failed:', error); console.error('Log campaign email failed:', error);
@ -358,7 +397,7 @@ class NocoDBService {
async getCampaignEmailCount(campaignId) { async getCampaignEmailCount(campaignId) {
try { try {
const response = await this.getAll(this.tableIds.campaignEmails, { const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`, where: `(Campaign ID,eq,${campaignId})`,
limit: 1000 // Get enough to count limit: 1000 // Get enough to count
}); });
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0); return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
@ -371,7 +410,7 @@ class NocoDBService {
async getCampaignAnalytics(campaignId) { async getCampaignAnalytics(campaignId) {
try { try {
const response = await this.getAll(this.tableIds.campaignEmails, { const response = await this.getAll(this.tableIds.campaignEmails, {
where: `(campaign_id,eq,${campaignId})`, where: `(Campaign ID,eq,${campaignId})`,
limit: 1000 limit: 1000
}); });
@ -379,32 +418,36 @@ class NocoDBService {
const analytics = { const analytics = {
totalEmails: emails.length, totalEmails: emails.length,
smtpEmails: emails.filter(e => e.email_method === 'smtp').length, smtpEmails: emails.filter(e => (e['Email Method'] || e.email_method) === 'smtp').length,
mailtoClicks: emails.filter(e => e.email_method === 'mailto').length, mailtoClicks: emails.filter(e => (e['Email Method'] || e.email_method) === 'mailto').length,
successfulEmails: emails.filter(e => e.status === 'sent' || e.status === 'clicked').length, successfulEmails: emails.filter(e => {
failedEmails: emails.filter(e => e.status === 'failed').length, const status = e['Status'] || e.status;
return status === 'sent' || status === 'clicked';
}).length,
failedEmails: emails.filter(e => (e['Status'] || e.status) === 'failed').length,
byLevel: {}, byLevel: {},
byDate: {}, byDate: {},
recentEmails: emails.slice(0, 10).map(email => ({ recentEmails: emails.slice(0, 10).map(email => ({
timestamp: email.timestamp, timestamp: email['Sent At'] || email.timestamp || email.sent_at,
user_name: email.user_name, user_name: email['User Name'] || email.user_name,
recipient_name: email.recipient_name, recipient_name: email['Recipient Name'] || email.recipient_name,
recipient_level: email.recipient_level, recipient_level: email['Government Level'] || email.recipient_level,
email_method: email.email_method, email_method: email['Email Method'] || email.email_method,
status: email.status status: email['Status'] || email.status
})) }))
}; };
// Group by government level // Group by government level
emails.forEach(email => { emails.forEach(email => {
const level = email.recipient_level || 'Other'; const level = email['Government Level'] || email.recipient_level || 'Other';
analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1; analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1;
}); });
// Group by date // Group by date
emails.forEach(email => { emails.forEach(email => {
if (email.timestamp) { const timestamp = email['Sent At'] || email.timestamp || email.sent_at;
const date = email.timestamp.split('T')[0]; // Get date part if (timestamp) {
const date = timestamp.split('T')[0]; // Get date part
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1; analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
} }
}); });

View File

@ -104,3 +104,7 @@ When adding a new feature, follow these steps:
4. **Document:** Update `README.md` and `files-explainer.md`. 4. **Document:** Update `README.md` and `files-explainer.md`.
5. **Test:** Manually test your feature in both desktop and mobile views. 5. **Test:** Manually test your feature in both desktop and mobile views.
6. **Pull Request:** Submit your changes for review. 6. **Pull Request:** Submit your changes for review.
## Visuals
We want a clean modern look. We use Leaflet.js for mapping. We use vanilla JS for the frontend. We want a responsive design that works well on both desktop and mobile. We want clear feedback for user actions (loading spinners, success/error messages). We want error handling to provide appropriate feedback when errors occur (both backend and frontend).

View File

@ -6,6 +6,13 @@
set -e # Exit on any error set -e # Exit on any error
# Change to the influence root directory (parent of scripts directory)
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INFLUENCE_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$INFLUENCE_ROOT"
echo "Changed to influence root directory: $INFLUENCE_ROOT"
# Colors for output # Colors for output
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
@ -619,7 +626,8 @@ create_campaign_emails_table() {
"options": [ "options": [
{"title": "sent", "color": "#c2f5e8"}, {"title": "sent", "color": "#c2f5e8"},
{"title": "failed", "color": "#ffdce5"}, {"title": "failed", "color": "#ffdce5"},
{"title": "clicked", "color": "#cfdffe"} {"title": "clicked", "color": "#cfdffe"},
{"title": "user_info_captured", "color": "#f0e68c"}
] ]
} }
}, },