tonne of imporvements, debugs, new ui
This commit is contained in:
parent
f93765f38b
commit
cd1099c428
@ -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,
|
||||||
@ -361,8 +367,10 @@ class CampaignsController {
|
|||||||
error: 'Campaign not found'
|
error: 'Campaign not found'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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 {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
5
influence/app/public/favicon.ico
Normal file
5
influence/app/public/favicon.ico
Normal 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 |
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
</div>
|
` : ''}
|
||||||
` : '<p style="text-align: center; color: #6c757d;">No email available</p>'}
|
${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>
|
</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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -103,4 +103,8 @@ When adding a new feature, follow these steps:
|
|||||||
3. **Frontend:** Add/modify JS modules in `/public/js`. Update HTML/CSS as needed.
|
3. **Frontend:** Add/modify JS modules in `/public/js`. Update HTML/CSS as needed.
|
||||||
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).
|
||||||
@ -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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user