A tonne of updates; site info, documentation, campaign phone numbers, soccial share buttons, and several other things

This commit is contained in:
2025-10-04 12:25:25 -06:00
parent e1daa57e79
commit ea9c38d860
77 changed files with 6383 additions and 2425 deletions

View File

@@ -8,6 +8,9 @@ A comprehensive web application that helps Alberta residents connect with their
- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives
- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations
- **Direct Email**: Built-in email composer to contact representatives
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
- **Responsive Design**: Works seamlessly on desktop and mobile devices
- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information
@@ -190,6 +193,111 @@ RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
```
## Campaign Management Guide
### Creating a Campaign
1. **Access Admin Panel**: Navigate to `/admin.html` and log in with admin credentials
2. **Create New Campaign**: Click "Create Campaign" button
3. **Configure Basic Settings**:
- **Campaign Title**: Short, descriptive name (becomes the URL slug)
- **Description**: Brief overview shown on the campaign landing page
- **Call to Action**: Motivational message encouraging participation
4. **Set Email Template**:
- **Email Subject**: Pre-filled subject line for emails
- **Email Body**: Default message template (users may edit if allowed)
5. **Upload Cover Photo** (Optional):
- Click "Choose File" to upload a hero image
- Supported formats: JPEG, PNG, GIF, WebP
- Maximum size: 5MB
- Image displays as campaign page banner
6. **Configure Campaign Settings**:
- **📧 Allow SMTP Email**: Enable server-side email sending
- **🔗 Allow Mailto Link**: Enable browser-based mailto: links
- **👤 Collect User Info**: Request user name and email
- **📊 Show Email Count**: Display total emails sent (engagement metric)
- **✏️ Allow Email Editing**: Let users customize email template
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
7. **Set Campaign Status**:
- **Draft**: Hidden from public, testing mode
- **Active**: Visible to public on main page
- **Paused**: Temporarily disabled
- **Archived**: Completed campaigns
8. **Save Campaign**: Click "Create Campaign" to publish
### Public Campaigns Display
The homepage automatically displays all active campaigns in a responsive grid below the representative lookup section.
**Features**:
- **Automatic Display**: Only active campaigns (status="active") are shown publicly
- **Campaign Cards**: Each campaign displays as an attractive card with:
- Cover photo (if uploaded) or gradient background
- Campaign title and truncated description
- Target government level badges (Federal, Provincial, Municipal, etc.)
- Email count badge (if enabled via campaign settings)
- "Learn More & Participate" call-to-action
- **Responsive Grid**: Automatically adjusts columns based on screen size
- Desktop: 3-4 columns
- Tablet: 2 columns
- Mobile: 1 column
- **Click Navigation**: Users can click any campaign card to visit the full campaign page
- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist
- **Security**: HTML content is escaped to prevent XSS attacks
- **Sorting**: Campaigns display newest first by creation date
**Public API Endpoint**: `/api/public/campaigns` (no authentication required)
- Returns only campaigns with `status='active'`
- Includes email counts when `show_email_count=true`
- Optimized for performance with minimal data transfer
### Email Count Display Feature
The **Show Email Count** setting controls whether campaign pages display total engagement metrics.
**When Enabled** (✅ checked):
- Campaign page shows: "X Albertans have sent emails through this campaign"
- Provides social proof and encourages participation
- Updates in real-time as users send emails
- Displays prominently above the call-to-action
**When Disabled** (❌ unchecked):
- Email count section is hidden
- Useful for sensitive campaigns or privacy concerns
- Engagement metrics still tracked in admin panel
**Best Practices**:
- ✅ Enable for public awareness campaigns to show momentum
- ✅ Enable for volunteer recruitment to demonstrate support
- ❌ Disable for personal advocacy or sensitive issues
- ❌ Disable for new campaigns until participation grows
**Technical Details**:
- Count includes all successfully sent emails via campaign
- Tracks both SMTP-sent and mailto-initiated emails (if logged)
- Admin panel always shows counts regardless of public display setting
- Database field: `show_email_count` (checkbox, default: true)
### Editing Campaigns
1. Navigate to Admin Panel → "Campaigns" tab
2. Find campaign card and click "Edit"
3. Modify any settings including the email count display toggle
4. Save changes - updates apply immediately to public-facing page
### Campaign Analytics
Access campaign performance metrics in the Admin Panel:
- Total emails sent per campaign
- User participation rates
- Email delivery status
- Representative contact distribution
## API Endpoints
### Representatives
@@ -211,12 +319,28 @@ RATE_LIMIT_MAX_REQUESTS=100
## Database Schema
### Campaigns Table
- slug, title, description
- email_subject, email_body
- call_to_action, cover_photo
- status (draft/active/paused/archived)
- allow_smtp_email, allow_mailto_link
- collect_user_info, **show_email_count**
- allow_email_editing
- target_government_levels (MultiSelect)
- created_by_user_id, created_by_user_email, created_by_user_name
### Campaign Emails Table
- campaign_id, user_name, user_email, user_postal_code
- recipient_name, recipient_email, recipient_level
- subject, message, status, sent_at
### Representatives Table
- postal_code, name, email, district_name
- elected_office, party_name, representative_set_name
- url, photo_url, cached_at
### Emails Table
### Email Logs Table
- recipient_email, recipient_name, sender_email
- subject, message, status, sent_at
@@ -224,6 +348,11 @@ RATE_LIMIT_MAX_REQUESTS=100
- postal_code, city, province
- centroid_lat, centroid_lng, last_updated
### Users Table
- email, password_hash, name
- role (admin/user), status (active/temporary)
- expires_at, last_login
## Development
### Project Structure
@@ -267,6 +396,16 @@ influence/
- Party affiliation and government level
- Direct links to official profiles
### Campaign System
- **Campaign Creation**: Create advocacy campaigns with custom titles, descriptions, and email templates
- **Cover Photos**: Upload hero images for campaign landing pages (JPEG/PNG/GIF/WebP, max 5MB)
- **Flexible Email Methods**: Choose between SMTP email or mailto links for user convenience
- **User Info Collection**: Optional name/email collection for campaign tracking
- **Email Count Display**: Show total engagement metrics on campaign pages (toggle on/off)
- **Email Editing**: Allow users to customize campaign email templates (optional)
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
### Email Integration
- Modal-based email composer
- Pre-filled recipient information

View File

@@ -90,6 +90,82 @@ async function cacheRepresentatives(postalCode, representatives, representData)
}
class CampaignsController {
// Get public campaigns (no authentication required)
async getPublicCampaigns(req, res, next) {
try {
const campaigns = await nocoDB.getAllCampaigns();
// Filter to only active campaigns and normalize data structure
const activeCampaigns = await Promise.all(
campaigns
.filter(campaign => {
const status = normalizeStatus(campaign['Status'] || campaign.status);
return status === 'active';
})
.map(async (campaign) => {
const id = campaign.ID || campaign.Id || campaign.id;
// Debug: Log specific fields we're looking for
console.log(`Campaign ${id}:`, {
'Show Call Count': campaign['Show Call Count'],
'show_call_count': campaign.show_call_count,
'Show Email Count': campaign['Show Email Count'],
'show_email_count': campaign.show_email_count
});
// Get email count if show_email_count is enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
console.log(`Getting email count for campaign ID: ${id}, showEmailCount: ${showEmailCount}`);
if (showEmailCount && id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
console.log(`Email count result: ${emailCount}`);
}
// Get call count if show_call_count is enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
console.log(`Getting call count for campaign ID: ${id}, showCallCount: ${showCallCount}`);
if (showCallCount && id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
console.log(`Call count result: ${callCount}`);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Return only public-facing information
return {
id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
show_email_count: showEmailCount,
show_call_count: showCallCount,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount
};
})
);
res.json({
success: true,
campaigns: activeCampaigns
});
} catch (error) {
console.error('Get public campaigns error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve campaigns',
message: error.message
});
}
}
// Get all campaigns (for admin panel)
async getAllCampaigns(req, res, next) {
try {
@@ -249,9 +325,23 @@ class CampaignsController {
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
if (showEmailCount) {
const id = campaign.Id ?? campaign.id;
const id = campaign.ID || campaign.Id || campaign.id;
console.log('Getting email count for campaign ID:', id);
if (id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
console.log('Email count result:', emailCount);
}
}
// Get call count if enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
if (showCallCount) {
const id = campaign.ID || campaign.Id || campaign.id;
console.log('Getting call count for campaign ID:', id);
if (id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
console.log('Call count result:', callCount);
}
}
@@ -274,9 +364,11 @@ class CampaignsController {
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
emailCount
emailCount,
callCount
}
});
} catch (error) {
@@ -419,6 +511,10 @@ class CampaignsController {
}
}
// Track old slug for cascade updates
const oldSlug = existingCampaign['Campaign Slug'] || existingCampaign.slug;
let newSlug = oldSlug;
if (updates.title) {
let slug = generateSlug(updates.title);
@@ -433,6 +529,7 @@ class CampaignsController {
}
}
updates.slug = slug;
newSlug = slug;
}
if (updates.target_government_levels !== undefined) {
@@ -472,6 +569,19 @@ class CampaignsController {
const campaign = await nocoDB.updateCampaign(id, updates);
// If slug changed, update references in related tables
if (oldSlug && newSlug && oldSlug !== newSlug) {
console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
if (cascadeResult.success) {
console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
} else {
console.warn(`Failed to update some slug references:`, cascadeResult.error);
// Don't fail the main update - cascade is a best-effort operation
}
}
res.json({
success: true,
campaign: {
@@ -892,6 +1002,74 @@ class CampaignsController {
});
}
}
// Track campaign phone call
async trackCampaignCall(req, res, next) {
try {
const { slug } = req.params;
const {
representativeName,
representativeTitle,
phoneNumber,
officeType,
userEmail,
userName,
postalCode
} = req.body;
// Validate required fields
if (!representativeName || !phoneNumber) {
return res.status(400).json({
success: false,
error: 'Representative name and phone number are required'
});
}
// Get campaign
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
});
}
// Log the call
await nocoDB.logCall({
representativeName,
representativeTitle: representativeTitle || null,
phoneNumber,
officeType: officeType || null,
callerName: userName || null,
callerEmail: userEmail || null,
postalCode: postalCode || null,
campaignId: campaign.ID || campaign.Id || campaign.id,
campaignSlug: slug,
callerIP: req.ip || req.connection?.remoteAddress || null,
timestamp: new Date().toISOString()
});
res.json({
success: true,
message: 'Call tracked successfully'
});
} catch (error) {
console.error('Track campaign call error:', error);
res.status(500).json({
success: false,
error: 'Failed to track call',
message: error.message
});
}
}
}
// Export controller instance and upload middleware

View File

@@ -180,6 +180,55 @@ class RepresentativesController {
});
}
}
async trackCall(req, res, next) {
try {
const {
representativeName,
representativeTitle,
phoneNumber,
officeType,
userEmail,
userName,
postalCode
} = req.body;
// Validate required fields
if (!representativeName || !phoneNumber) {
return res.status(400).json({
success: false,
error: 'Representative name and phone number are required'
});
}
// Log the call
await nocoDB.logCall({
representativeName,
representativeTitle: representativeTitle || null,
phoneNumber,
officeType: officeType || null,
callerName: userName || null,
callerEmail: userEmail || null,
postalCode: postalCode || null,
campaignId: null,
campaignSlug: null,
callerIP: req.ip || req.connection?.remoteAddress || null,
timestamp: new Date().toISOString()
});
res.json({
success: true,
message: 'Call tracked successfully'
});
} catch (error) {
console.error('Track call error:', error);
res.status(500).json({
success: false,
error: 'Failed to track call',
message: error.message
});
}
}
}
module.exports = new RepresentativesController();

View File

@@ -300,10 +300,18 @@
</div>
<div class="campaign-content">
<!-- Email Count Display -->
<!-- Campaign Stats Display -->
<div id="campaign-stats" class="campaign-stats" style="display: none;">
<div class="email-count" id="email-count">0</div>
<p>Albertans have sent emails through this campaign</p>
<div style="display: flex; gap: 2rem; justify-content: center; flex-wrap: wrap;">
<div id="email-count-container" style="display: none;">
<div class="email-count" id="email-count">0</div>
<p>Emails sent</p>
</div>
<div id="call-count-container" style="display: none;">
<div class="email-count" id="call-count">0</div>
<p>Calls made</p>
</div>
</div>
</div>
<!-- Call to Action -->

View File

@@ -1064,4 +1064,359 @@ footer a:hover {
overflow: hidden;
text-overflow: ellipsis;
}
}
}
/* ===================================
CAMPAIGNS GRID STYLES
=================================== */
#campaigns-section {
margin-top: 60px;
padding-top: 40px;
border-top: 2px solid #e0e0e0;
}
.campaigns-section-header {
text-align: center;
margin-bottom: 40px;
}
.campaigns-section-header h2 {
color: #005a9c;
font-size: 2em;
margin-bottom: 10px;
}
.campaigns-section-header p {
color: #666;
font-size: 1.1em;
}
#campaigns-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 30px;
margin: 0 auto;
}
.campaign-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
cursor: pointer;
display: flex;
flex-direction: column;
height: 100%;
outline: none;
}
.campaign-card:hover,
.campaign-card:focus {
transform: translateY(-5px);
box-shadow: 0 8px 20px rgba(0, 90, 156, 0.2);
}
.campaign-card:focus {
outline: 2px solid #005a9c;
outline-offset: 2px;
}
.campaign-card-image {
width: 100%;
height: 200px;
position: relative;
overflow: hidden;
}
.campaign-card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.3);
transition: background 0.3s ease;
}
.campaign-card:hover .campaign-card-overlay {
background: rgba(0, 0, 0, 0.4);
}
.campaign-card-content {
padding: 20px;
display: flex;
flex-direction: column;
flex: 1;
}
.campaign-card-title {
color: #005a9c;
font-size: 1.4em;
margin-bottom: 12px;
font-weight: 600;
line-height: 1.3;
}
.campaign-card-description {
color: #555;
font-size: 0.95em;
line-height: 1.6;
margin-bottom: 16px;
flex: 1;
}
.campaign-card-levels {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 12px;
}
.level-badge {
background: #e8f4f8;
color: #005a9c;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.8em;
font-weight: 500;
}
.campaign-card-stats {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
@media (min-width: 768px) {
.campaign-card-stats {
flex-direction: row;
gap: 12px;
}
.campaign-card-stat {
flex: 1;
margin-bottom: 0;
}
}
.campaign-card-stat {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
}
.stat-icon {
font-size: 1.2em;
}
.stat-value {
font-size: 1.3em;
font-weight: 700;
color: #005a9c;
}
.stat-label {
font-size: 0.85em;
color: #666;
}
.campaign-card-social-share {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 0;
margin-bottom: 8px;
flex-wrap: wrap;
}
.share-label {
font-size: 0.85em;
color: #666;
font-weight: 500;
margin-right: 4px;
}
.share-btn {
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
background: #f0f0f0;
color: #666;
padding: 6px;
}
.share-btn svg {
width: 18px;
height: 18px;
}
.share-btn:hover {
transform: scale(1.1);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.share-btn:focus {
outline: 2px solid #005a9c;
outline-offset: 2px;
}
.share-twitter:hover {
background: #000000;
color: white;
}
.share-facebook:hover {
background: #1877f2;
color: white;
}
.share-linkedin:hover {
background: #0077b5;
color: white;
}
.share-reddit:hover {
background: #ff4500;
color: white;
}
.share-email:hover {
background: #005a9c;
color: white;
}
.share-copy:hover {
background: #34a853;
color: white;
}
.share-feedback {
position: fixed;
bottom: 20px;
right: 20px;
background: #34a853;
color: white;
padding: 12px 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
font-size: 0.9em;
font-weight: 500;
opacity: 0;
transform: translateY(20px);
transition: all 0.3s ease;
z-index: 10000;
pointer-events: none;
}
.share-feedback.show {
opacity: 1;
transform: translateY(0);
}
.share-feedback.error {
background: #dc3545;
}
.campaign-card-action {
margin-top: auto;
padding-top: 12px;
border-top: 1px solid #e0e0e0;
}
.btn-link {
color: #005a9c;
font-weight: 600;
font-size: 0.95em;
text-decoration: none;
transition: color 0.2s ease;
}
.campaign-card:hover .btn-link {
color: #004a7c;
}
.campaigns-loading,
.campaigns-error,
.campaigns-empty {
text-align: center;
padding: 60px 20px;
color: #666;
}
.campaigns-loading .spinner {
width: 50px;
height: 50px;
border: 4px solid #f3f3f3;
border-top: 4px solid #005a9c;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
.campaigns-error {
color: #d32f2f;
}
.campaigns-empty {
background: #f8f9fa;
border-radius: 8px;
padding: 40px;
}
/* Responsive campaign grid styles */
@media (max-width: 1024px) {
#campaigns-grid {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
}
@media (max-width: 768px) {
#campaigns-section {
margin-top: 40px;
padding-top: 30px;
}
.campaigns-section-header h2 {
font-size: 1.75em;
}
#campaigns-grid {
grid-template-columns: 1fr;
gap: 20px;
}
.campaign-card-image {
height: 180px;
}
}
@media (max-width: 480px) {
.campaigns-section-header h2 {
font-size: 1.5em;
}
.campaign-card-title {
font-size: 1.2em;
}
.campaign-card-image {
height: 160px;
}
.campaign-card-content {
padding: 16px;
}
}

View File

@@ -179,12 +179,28 @@
<!-- Success/Error Messages -->
<div id="message-display" class="message-display" style="display: none;"></div>
<!-- Campaigns Section -->
<section id="campaigns-section" style="display: none;">
<div class="campaigns-section-header">
<h2>Active Campaigns</h2>
<p>Join ongoing campaigns to make your voice heard on important issues</p>
</div>
<div id="campaigns-grid">
<!-- Campaign cards will be dynamically inserted here -->
</div>
</section>
</main>
<footer>
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
<p><small><a href="terms.html" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div class="preamble" style="text-align: center; padding: 1rem; margin: 1rem 0; background-color: #f5f5f5; border-radius: 8px;">
<p>Influence is an open-source platform and the code is available to all at <a href="https://gitea.bnkops.com/admin/changemaker.lite" target="_blank" rel="noopener noreferrer">gitea.bnkops.com/admin/changemaker.lite</a></p>
</div>
<div class="footer-actions">
<a href="/login.html" class="btn btn-secondary">Admin Login</a>
</div>
@@ -198,6 +214,7 @@
<script src="js/api-client.js"></script>
<script src="js/auth.js"></script>
<script src="js/campaigns-grid.js"></script>
<script src="js/postal-lookup.js"></script>
<script src="js/representatives-display.js"></script>
<script src="js/email-composer.js"></script>

View File

@@ -553,6 +553,7 @@ class AdminPanel {
campaignFormData.append('allow_mailto_link', formData.get('allow_mailto_link') === 'on');
campaignFormData.append('collect_user_info', formData.get('collect_user_info') === 'on');
campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on');
campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
@@ -659,6 +660,7 @@ class AdminPanel {
updateFormData.append('allow_mailto_link', formData.get('allow_mailto_link') === 'on');
updateFormData.append('collect_user_info', formData.get('collect_user_info') === 'on');
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));

View File

@@ -75,10 +75,26 @@ class CampaignPage {
headerElement.style.backgroundImage = '';
}
// Show email count if enabled
if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
// Show email count if enabled (show even if count is 0)
const statsSection = document.getElementById('campaign-stats');
let hasStats = false;
if (this.campaign.show_email_count && this.campaign.emailCount !== null && this.campaign.emailCount !== undefined) {
document.getElementById('email-count').textContent = this.campaign.emailCount;
document.getElementById('campaign-stats').style.display = 'block';
document.getElementById('email-count-container').style.display = 'block';
hasStats = true;
}
// Show call count if enabled (show even if count is 0)
if (this.campaign.show_call_count && this.campaign.callCount !== null && this.campaign.callCount !== undefined) {
document.getElementById('call-count').textContent = this.campaign.callCount;
document.getElementById('call-count-container').style.display = 'block';
hasStats = true;
}
// Show stats section if any stat is enabled
if (hasStats) {
statsSection.style.display = 'block';
}
// Show call to action
@@ -461,21 +477,26 @@ class CampaignPage {
async trackCall(phone, name, title, officeType) {
try {
await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name,
representativeTitle: title || '',
phoneNumber: phone,
officeType: officeType || '',
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientPhone: phone,
recipientName: name,
recipientTitle: title,
officeType: officeType
postalCode: this.userInfo.postalCode
})
});
const data = await response.json();
if (data.success) {
this.showCallSuccess('Call tracked successfully!');
}
} catch (error) {
console.error('Failed to track call:', error);
}
@@ -627,6 +648,18 @@ class CampaignPage {
// You could show a toast or update UI to indicate success
alert(message); // Simple for now, could be improved with better UI
}
showCallSuccess(message) {
// Update call count if enabled
if (this.campaign.show_call_count) {
const countElement = document.getElementById('call-count');
const currentCount = parseInt(countElement.textContent) || 0;
countElement.textContent = currentCount + 1;
}
// Show success message
alert(message);
}
}
// Initialize the campaign page when DOM is loaded

View File

@@ -0,0 +1,326 @@
// Campaigns Grid Module
// Displays public campaigns in a responsive grid on the homepage
class CampaignsGrid {
constructor() {
this.campaigns = [];
this.container = null;
this.loading = false;
this.error = null;
}
async init() {
this.container = document.getElementById('campaigns-grid');
if (!this.container) {
console.error('Campaigns grid container not found');
return;
}
await this.loadCampaigns();
}
async loadCampaigns() {
if (this.loading) return;
this.loading = true;
this.showLoading();
try {
const response = await fetch('/api/public/campaigns');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load campaigns');
}
this.campaigns = data.campaigns || [];
this.renderCampaigns();
// Show or hide the entire campaigns section based on availability
const campaignsSection = document.getElementById('campaigns-section');
if (this.campaigns.length > 0) {
campaignsSection.style.display = 'block';
} else {
campaignsSection.style.display = 'none';
}
} catch (error) {
console.error('Error loading campaigns:', error);
this.showError('Unable to load campaigns. Please try again later.');
} finally {
this.loading = false;
}
}
renderCampaigns() {
if (!this.container) return;
if (this.campaigns.length === 0) {
this.container.innerHTML = `
<div class="campaigns-empty">
<p>No active campaigns at the moment. Check back soon!</p>
</div>
`;
return;
}
// Sort campaigns by created_at date (newest first)
const sortedCampaigns = [...this.campaigns].sort((a, b) => {
const dateA = new Date(a.created_at || 0);
const dateB = new Date(b.created_at || 0);
return dateB - dateA;
});
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
this.container.innerHTML = campaignsHTML;
// Add click event listeners to campaign cards (no inline handlers)
this.attachCardClickHandlers();
}
attachCardClickHandlers() {
const campaignCards = this.container.querySelectorAll('.campaign-card');
campaignCards.forEach(card => {
const slug = card.getAttribute('data-slug');
if (slug) {
// Handle card click (but not share buttons)
card.addEventListener('click', (e) => {
// Don't navigate if clicking on share buttons
if (e.target.closest('.share-btn') || e.target.closest('.campaign-card-social-share')) {
return;
}
window.location.href = `/campaign/${slug}`;
});
// Add keyboard accessibility
card.setAttribute('tabindex', '0');
card.setAttribute('role', 'link');
card.setAttribute('aria-label', `View campaign: ${card.querySelector('.campaign-card-title')?.textContent || 'campaign'}`);
card.addEventListener('keypress', (e) => {
if (e.target.closest('.share-btn')) {
return; // Let share buttons handle their own keyboard events
}
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
window.location.href = `/campaign/${slug}`;
}
});
// Attach share button handlers
const shareButtons = card.querySelectorAll('.share-btn');
shareButtons.forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
e.preventDefault();
const platform = btn.getAttribute('data-platform');
const title = card.querySelector('.campaign-card-title')?.textContent || 'Campaign';
const description = card.querySelector('.campaign-card-description')?.textContent || '';
this.handleShare(platform, slug, title, description);
});
});
}
});
}
renderCampaignCard(campaign) {
const coverPhotoStyle = campaign.cover_photo
? `background-image: url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
: 'background: linear-gradient(135deg, #3498db, #2c3e50);';
const emailCountBadge = campaign.show_email_count && campaign.emailCount !== null
? `<div class="campaign-card-stat">
<span class="stat-icon">📧</span>
<span class="stat-value">${campaign.emailCount}</span>
<span class="stat-label">emails sent</span>
</div>`
: '';
const callCountBadge = campaign.show_call_count && campaign.callCount !== null
? `<div class="campaign-card-stat">
<span class="stat-icon">📞</span>
<span class="stat-value">${campaign.callCount}</span>
<span class="stat-label">calls made</span>
</div>`
: '';
const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0
? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('')
: '';
// Truncate description to reasonable length
const description = campaign.description || '';
const truncatedDescription = description.length > 150
? description.substring(0, 150) + '...'
: description;
return `
<div class="campaign-card" data-slug="${campaign.slug}">
<div class="campaign-card-image" style="${coverPhotoStyle}">
<div class="campaign-card-overlay"></div>
</div>
<div class="campaign-card-content">
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
<div class="campaign-card-stats">
${emailCountBadge}
${callCountBadge}
</div>
<div class="campaign-card-social-share">
<span class="share-label">Share:</span>
<button class="share-btn share-twitter" data-platform="twitter" title="Share on Twitter/X" aria-label="Share on Twitter/X">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn share-facebook" data-platform="facebook" title="Share on Facebook" aria-label="Share on Facebook">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn share-linkedin" data-platform="linkedin" title="Share on LinkedIn" aria-label="Share on LinkedIn">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn share-reddit" data-platform="reddit" title="Share on Reddit" aria-label="Share on Reddit">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>
</button>
<button class="share-btn share-email" data-platform="email" title="Share via Email" aria-label="Share via Email">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
<button class="share-btn share-copy" data-platform="copy" title="Copy Link" aria-label="Copy Link">
<svg viewBox="0 0 24 24" fill="currentColor">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
</div>
<div class="campaign-card-action">
<span class="btn-link">Learn More & Participate →</span>
</div>
</div>
</div>
`;
}
showLoading() {
if (!this.container) return;
this.container.innerHTML = `
<div class="campaigns-loading">
<div class="spinner"></div>
<p>Loading campaigns...</p>
</div>
`;
}
showError(message) {
if (!this.container) return;
this.container.innerHTML = `
<div class="campaigns-error">
<p>⚠️ ${this.escapeHtml(message)}</p>
</div>
`;
}
escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
handleShare(platform, slug, title, description) {
const campaignUrl = `${window.location.origin}/campaign/${slug}`;
const shareText = `${title} - ${description}`;
let shareUrl = '';
switch(platform) {
case 'twitter':
shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'facebook':
shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'linkedin':
shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(campaignUrl)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'reddit':
shareUrl = `https://www.reddit.com/submit?url=${encodeURIComponent(campaignUrl)}&title=${encodeURIComponent(title)}`;
window.open(shareUrl, '_blank', 'width=550,height=420');
break;
case 'email':
const emailSubject = `Check out this campaign: ${title}`;
const emailBody = `${shareText}\n\nLearn more and participate: ${campaignUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(emailBody)}`;
break;
case 'copy':
this.copyToClipboard(campaignUrl);
break;
}
}
async copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
}
this.showShareFeedback('Link copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
this.showShareFeedback('Failed to copy link', true);
}
}
showShareFeedback(message, isError = false) {
// Create or get feedback element
let feedback = document.getElementById('share-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.id = 'share-feedback';
feedback.className = 'share-feedback';
document.body.appendChild(feedback);
}
feedback.textContent = message;
feedback.className = `share-feedback ${isError ? 'error' : 'success'} show`;
// Auto-hide after 3 seconds
setTimeout(() => {
feedback.classList.remove('show');
}, 3000);
}
}
// Export for use in main.js
if (typeof window !== 'undefined') {
window.CampaignsGrid = CampaignsGrid;
}

View File

@@ -157,6 +157,12 @@ window.Utils = Utils;
document.addEventListener('DOMContentLoaded', () => {
window.mainApp = new MainApp();
// Initialize campaigns grid
if (typeof CampaignsGrid !== 'undefined') {
window.campaignsGrid = new CampaignsGrid();
window.campaignsGrid.init();
}
// Add some basic accessibility improvements
document.addEventListener('keydown', (e) => {
// Allow Escape to close modals (handled in individual modules)

View File

@@ -111,6 +111,10 @@ class PostalLookup {
const data = await window.apiClient.getRepresentativesByPostalCode(postalCode);
this.currentPostalCode = postalCode;
// Store postal code globally for call tracking
window.lastLookupPostalCode = postalCode;
this.displayResults(data);
} catch (error) {

View File

@@ -427,6 +427,32 @@ class RepresentativesDisplay {
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
// Track the call
this.trackCall(phone, name, office, officeType);
}
}
async trackCall(phone, name, office, officeType) {
try {
await fetch('/api/track-call', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name,
representativeTitle: office || '',
phoneNumber: phone,
officeType: officeType || '',
postalCode: window.lastLookupPostalCode || null,
userEmail: null,
userName: null
})
});
} catch (error) {
console.error('Failed to track call:', error);
// Don't show error to user - tracking is non-critical
}
}

View File

@@ -140,6 +140,7 @@ router.put(
router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaignsController.getCampaignAnalytics);
// Campaign endpoints (Public)
router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
@@ -164,6 +165,30 @@ router.post(
campaignsController.sendCampaignEmail
);
// Campaign call tracking endpoint
router.post(
'/campaigns/:slug/track-call',
rateLimiter.general,
[
body('representativeName').notEmpty().withMessage('Representative name is required'),
body('phoneNumber').notEmpty().withMessage('Phone number is required')
],
handleValidationErrors,
campaignsController.trackCampaignCall
);
// General call tracking endpoint (non-campaign)
router.post(
'/track-call',
rateLimiter.general,
[
body('representativeName').notEmpty().withMessage('Representative name is required'),
body('phoneNumber').notEmpty().withMessage('Phone number is required')
],
handleValidationErrors,
representativesController.trackCall
);
// User management routes (admin only)
router.use('/admin/users', userRoutes);

View File

@@ -25,7 +25,8 @@ class NocoDBService {
postalCodes: process.env.NOCODB_TABLE_POSTAL_CODES,
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
users: process.env.NOCODB_TABLE_USERS
users: process.env.NOCODB_TABLE_USERS,
calls: process.env.NOCODB_TABLE_CALLS
};
// Validate that all table IDs are set
@@ -381,7 +382,9 @@ class NocoDBService {
async getAllCampaigns() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
sort: '-CreatedAt'
sort: '-CreatedAt',
// Explicitly request all fields to ensure newly added columns are included
fields: '*'
});
return response.list || [];
} catch (error) {
@@ -543,6 +546,24 @@ class NocoDBService {
}
}
async getCampaignCallCount(campaignId) {
try {
if (!this.tableIds.calls) {
console.warn('Calls table not configured, returning 0');
return 0;
}
const response = await this.getAll(this.tableIds.calls, {
where: `(Campaign ID,eq,${campaignId})`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign call count failed:', error);
return 0;
}
}
async getCampaignAnalytics(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {

View File

@@ -6,6 +6,109 @@ This document explains the purpose and functionality of each file in the BNKops
The BNKops Influence Campaign Tool is a comprehensive political engagement platform that allows users to create and participate in advocacy campaigns, contact their elected representatives, and track campaign analytics. The application features campaign management, user authentication, representative lookup via the Represent API, email composition and sending, and detailed analytics tracking.
## Campaign Settings System
The application includes a flexible campaign configuration system that allows administrators to customize campaign behavior and appearance. Campaign settings control user experience, data collection, and engagement tracking.
### Core Campaign Settings
**Email Count Display** (`show_email_count`):
- **Purpose**: Controls visibility of total emails sent metric on campaign landing pages
- **Implementation**: Checkbox field in campaigns table (default: true)
- **When Enabled**: Shows "X Albertans have sent emails through this campaign" banner
- **When Disabled**: Hides public email count while maintaining admin tracking
- **Use Cases**:
- ✅ Enable for public campaigns to demonstrate momentum and social proof
- ❌ Disable for sensitive campaigns or when starting with low participation
- **Database**: `show_email_count` BOOLEAN field in campaigns table
- **Backend**: `getCampaignEmailCount()` fetches count when setting is enabled
- **Frontend**: `campaign.js` shows/hides `campaign-stats` div based on setting
- **Admin Panel**: Checkbox labeled "📊 Show Email Count" in create/edit forms
**SMTP Email** (`allow_smtp_email`):
- Enables server-side email sending through SMTP configuration
- When enabled, emails sent via backend with full logging and tracking
**Mailto Links** (`allow_mailto_link`):
- Allows browser-based email client launching via mailto: URLs
- Useful fallback when SMTP isn't configured or for user preference
**User Info Collection** (`collect_user_info`):
- When enabled, requests user's name and email before showing representatives
- Supports campaign tracking and follow-up communications
**Email Editing** (`allow_email_editing`):
- Allows users to customize email subject and body before sending
- When disabled, forces use of campaign template only
**Cover Photo** (`cover_photo`):
- Upload custom hero images for campaign landing pages
- Supported formats: JPEG, PNG, GIF, WebP (max 5MB)
- Displays as full-width background with overlay on campaign pages
**Target Government Levels** (`target_government_levels`):
- MultiSelect field for Federal, Provincial, Municipal, School Board
- Filters which representatives are displayed to campaign participants
**Campaign Status** (`status`):
- Draft: Hidden from public, testing only
- Active: Visible on main page and accessible via slug URL
- Paused: Temporarily disabled
- Archived: Completed campaigns, read-only
### Technical Implementation
**Database Schema** (`build-nocodb.sh`):
```javascript
{
"column_name": "show_email_count",
"title": "Show Email Count",
"uidt": "Checkbox",
"cdf": "true" // Default enabled
}
```
**Backend API** (`campaigns.js`):
- `getCampaignBySlug()`: Checks `show_email_count` setting and conditionally fetches email count
- `getAllCampaigns()`: Includes email count for all campaigns (admin view)
- `getCampaignEmailCount()`: Queries campaign_emails table for total sent emails
**Frontend Display** (`campaign.js`):
```javascript
if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
document.getElementById('email-count').textContent = this.campaign.emailCount;
document.getElementById('campaign-stats').style.display = 'block';
}
```
**Admin Interface** (`admin.html`):
- Create form: Checkbox with `id="create-show-count"` and `name="show_email_count"`
- Edit form: Checkbox with `id="edit-show-count"` pre-populated from campaign data
- Default state: Checked (enabled) for new campaigns
**Service Layer** (`nocodb.js`):
- Field mapping: `'Show Email Count': campaignData.show_email_count`
- Create/Update operations: Properly formats boolean value for NocoDB API
- Read operations: Normalizes field from NocoDB title to JavaScript property name
### Campaign Email Count Tracking
The email count feature tracks engagement across campaign participation. The system counts emails sent through:
- SMTP-based email sending (fully tracked with delivery status)
- Campaign email logs in NocoDB campaign_emails table
- Associated with specific campaign IDs for accurate attribution
**Count Calculation**:
- Queries campaign_emails table with `(Campaign ID,eq,{campaignId})` filter
- Returns total count of matching records
- Updates in real-time as users participate
- Cached in campaign response for performance
**Display Logic**:
- Only shows count when both `show_email_count=true` AND `emailCount > 0`
- Handles null/undefined counts gracefully
- Updates without page refresh using dynamic JavaScript rendering
## Authentication System
The application includes a complete authentication system supporting both admin and regular user access. Authentication is implemented using NocoDB as the user database, bcryptjs for password hashing, and express-session for session management. The system supports temporary users with expiration dates for campaign-specific access.
@@ -67,6 +170,7 @@ Business logic layer that handles HTTP requests and responses:
- Updates last login timestamps and manages session persistence
- **`campaigns.js`** - Core campaign management functionality with comprehensive CRUD operations
- `getPublicCampaigns()` - **Public endpoint** retrieves only active campaigns without authentication, returns filtered data with email counts if enabled
- `getAllCampaigns()` - Retrieves campaigns with filtering, pagination, and user permissions
- `createCampaign()` - Creates new campaigns with validation, slug generation, and cover photo upload handling using multer
- `updateCampaign()` - Updates campaign details, status management, and cover photo processing
@@ -116,6 +220,11 @@ API endpoint definitions and request validation:
- **`api.js`** - Main API routes with extensive validation middleware
- Campaign management endpoints: CRUD operations for campaigns, participation, analytics
- GET `/api/public/campaigns` - **Public endpoint** (no auth required) returns all active campaigns with email counts if enabled
- GET `/api/campaigns/:slug` - Public campaign lookup by URL slug for campaign landing pages
- GET `/api/campaigns/:slug/representatives/:postalCode` - Get representatives for a campaign by postal code
- POST `/api/campaigns/:slug/track-user` - Track user participation in campaigns
- POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives
- Representative endpoints with postal code validation and caching
- Email endpoints with input sanitization, template support, and test mode
- Email management: `/api/emails/preview`, `/api/emails/send`, `/api/emails/logs`, `/api/emails/test`
@@ -356,6 +465,19 @@ Professional HTML and text email templates with variable substitution:
- Progress tracking through campaign participation workflow
- Social sharing and engagement tracking functionality
- **`campaigns-grid.js`** - Public campaigns grid display for homepage
- `CampaignsGrid` class for displaying active campaigns in a responsive card layout
- Fetches public campaigns via `/api/public/campaigns` endpoint (no authentication required)
- Dynamic grid rendering with automatic responsive columns
- Campaign card generation with cover photos, titles, descriptions, and engagement stats
- Email count display when enabled via campaign settings
- Target government level badges for filtering context
- Click-to-navigate functionality to individual campaign pages
- Loading states and error handling with user-friendly messages
- Automatic show/hide of campaigns section based on availability
- HTML escaping for security against XSS attacks
- Sort campaigns by creation date (newest first)
- **`dashboard.js`** - User dashboard and analytics interface
- `UserDashboard` class managing personalized user experience
- Campaign management interface for user-created campaigns

View File

@@ -1,296 +0,0 @@
#!/bin/bash
# Fix Campaigns Table Script
# This script recreates the campaigns table with proper column options
set -e # Exit on any error
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Function to print colored output
print_status() {
echo -e "${BLUE}$1${NC}"
}
print_success() {
echo -e "${GREEN}$1${NC}"
}
print_warning() {
echo -e "${YELLOW}$1${NC}"
}
print_error() {
echo -e "${RED}$1${NC}"
}
# Load environment variables
if [ -f ".env" ]; then
export $(cat .env | grep -v '^#' | xargs)
print_success "Environment variables loaded from .env"
else
print_error "No .env file found. Please create one based on .env.example"
exit 1
fi
# Validate required environment variables
if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ] || [ -z "$NOCODB_PROJECT_ID" ]; then
print_error "Missing required environment variables: NOCODB_API_URL, NOCODB_API_TOKEN, NOCODB_PROJECT_ID"
exit 1
fi
print_status "Using NocoDB instance: $NOCODB_API_URL"
print_status "Project ID: $NOCODB_PROJECT_ID"
# Function to make API calls with proper error handling
make_api_call() {
local method="$1"
local url="$2"
local data="$3"
local description="$4"
print_status "Making $method request to: $url"
if [ -n "$description" ]; then
print_status "Purpose: $description"
fi
local response
local http_code
if [ "$method" = "DELETE" ]; then
response=$(curl -s -w "\n%{http_code}" -X DELETE \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
"$url")
elif [ "$method" = "POST" ] && [ -n "$data" ]; then
response=$(curl -s -w "\n%{http_code}" -X POST \
-H "xc-token: $NOCODB_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$data" \
"$url")
else
print_error "Invalid method or missing data for API call"
return 1
fi
# Extract HTTP code and response body
http_code=$(echo "$response" | tail -n1)
response_body=$(echo "$response" | head -n -1)
print_status "HTTP Status: $http_code"
if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
print_success "API call successful"
echo "$response_body"
return 0
else
print_error "API call failed with status $http_code"
print_error "Response: $response_body"
return 1
fi
}
# Function to delete the campaigns table
delete_campaigns_table() {
local table_id="$1"
print_warning "Deleting existing campaigns table (ID: $table_id)..."
make_api_call "DELETE" \
"$NOCODB_API_URL/db/meta/tables/$table_id" \
"" \
"Delete campaigns table" > /dev/null
}
# Function to create the campaigns table with proper options
create_campaigns_table() {
local base_id="$1"
print_status "Creating new campaigns table..."
local table_data='{
"table_name": "influence_campaigns",
"title": "Campaigns",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"unique": true,
"rqd": true
},
{
"column_name": "title",
"title": "Campaign Title",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "description",
"title": "Description",
"uidt": "LongText"
},
{
"column_name": "email_subject",
"title": "Email Subject",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "email_body",
"title": "Email Body",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "call_to_action",
"title": "Call to Action",
"uidt": "LongText"
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"colOptions": {
"options": [
{"title": "draft", "color": "#cfdffe"},
{"title": "active", "color": "#c2f5e8"},
{"title": "paused", "color": "#fee2d5"},
{"title": "archived", "color": "#ffeab6"}
]
},
"rqd": true,
"cdf": "draft"
},
{
"column_name": "allow_smtp_email",
"title": "Allow SMTP Email",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "allow_mailto_link",
"title": "Allow Mailto Link",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "collect_user_info",
"title": "Collect User Info",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "show_email_count",
"title": "Show Email Count",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "target_government_levels",
"title": "Target Government Levels",
"uidt": "MultiSelect",
"colOptions": {
"options": [
{"title": "Federal", "color": "#cfdffe"},
{"title": "Provincial", "color": "#d0f1fd"},
{"title": "Municipal", "color": "#c2f5e8"},
{"title": "School Board", "color": "#ffdaf6"}
]
}
},
{
"column_name": "created_at",
"title": "Created At",
"uidt": "DateTime",
"cdf": "now()"
},
{
"column_name": "updated_at",
"title": "Updated At",
"uidt": "DateTime",
"cdf": "now()"
}
]
}'
local response
response=$(make_api_call "POST" \
"$NOCODB_API_URL/db/meta/bases/$base_id/tables" \
"$table_data" \
"Create campaigns table with proper column options")
if [ $? -eq 0 ]; then
# Extract table ID from response
local table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
if [ -n "$table_id" ]; then
print_success "New campaigns table created with ID: $table_id"
echo "$table_id"
return 0
else
print_error "Could not extract table ID from response"
return 1
fi
else
return 1
fi
}
# Main execution
print_status "Starting campaigns table fix..."
# Check if campaigns table exists
if [ -n "$NOCODB_TABLE_CAMPAIGNS" ]; then
print_status "Found existing campaigns table ID: $NOCODB_TABLE_CAMPAIGNS"
# Delete existing table
if delete_campaigns_table "$NOCODB_TABLE_CAMPAIGNS"; then
print_success "Successfully deleted old campaigns table"
else
print_warning "Failed to delete old table, continuing anyway..."
fi
fi
# Create new table
NEW_TABLE_ID=$(create_campaigns_table "$NOCODB_PROJECT_ID")
if [ $? -eq 0 ] && [ -n "$NEW_TABLE_ID" ]; then
print_success "Successfully created new campaigns table!"
# Update .env file with new table ID
print_status "Updating .env file with new table ID..."
if grep -q "NOCODB_TABLE_CAMPAIGNS=" .env; then
# Replace existing NOCODB_TABLE_CAMPAIGNS
sed -i "s/NOCODB_TABLE_CAMPAIGNS=.*/NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID/" .env
print_success "Updated NOCODB_TABLE_CAMPAIGNS in .env file"
else
# Add new NOCODB_TABLE_CAMPAIGNS
echo "NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID" >> .env
print_success "Added NOCODB_TABLE_CAMPAIGNS to .env file"
fi
print_status ""
print_status "============================================================"
print_success "Campaigns table fix completed successfully!"
print_status "============================================================"
print_status ""
print_status "New table ID: $NEW_TABLE_ID"
print_status "Please restart your application to use the new table."
else
print_error "Failed to create new campaigns table"
exit 1
fi

File diff suppressed because it is too large Load Diff

View File

@@ -14,7 +14,81 @@ Wej are using NocoDB as a no-code database solution. You will need to set up a N
- **Purpose:** Create influence campaigns by identifying and engaging with key community figures over email, text, or phone.
- **Backend:** Node.js/Express, with NocoDB as the database (REST API).
- **Frontend:** Vanilla JS, Leaflet.js for mapping, modular code in `/public/js`.
- **Admin Panel:** Accessible via `/admin.html` for managing start location, walk sheet, cuts, and settings.
- **Admin Panel:** Accessible via `/admin.html` for managing campaigns, users, and settings.
## Campaign Settings Overview
The application supports flexible campaign configuration through the admin panel:
### Available Campaign Settings
1. **Show Email Count** (`show_email_count`) - **Default: ON**
- Displays total emails sent on campaign landing pages
- Provides social proof and engagement metrics
- Toggle via checkbox: "📊 Show Email Count" in admin panel
- **Database**: Boolean field in campaigns table
- **Backend**: Conditionally fetches count via `getCampaignEmailCount()`
- **Frontend**: Shows/hides stats banner in `campaign.js`
2. **Allow SMTP Email** (`allow_smtp_email`) - **Default: ON**
- Enables server-side email sending through configured SMTP
- Full logging and tracking of email delivery
3. **Allow Mailto Link** (`allow_mailto_link`) - **Default: ON**
- Enables browser-based email client launching
- Useful fallback for users without SMTP
4. **Collect User Info** (`collect_user_info`) - **Default: ON**
- Requests user name and email before participation
- Enables campaign tracking and follow-up
5. **Allow Email Editing** (`allow_email_editing`) - **Default: OFF**
- Lets users customize email templates before sending
- Increases personalization but may dilute messaging
6. **Cover Photo** (`cover_photo`) - **Optional**
- Hero image for campaign landing pages
- Max 5MB, JPEG/PNG/GIF/WebP formats
7. **Target Government Levels** (`target_government_levels`) - **MultiSelect**
- Federal, Provincial, Municipal, School Board
- Filters which representatives are shown
8. **Campaign Status** (`status`) - **Required**
- Draft: Testing only, hidden from public
- Active: Visible on main page
- Paused: Temporarily disabled
- Archived: Completed campaigns
### Using Campaign Settings in Code
**When creating new campaign features:**
- Add field to `build-nocodb.sh` campaigns table schema
- Add field mapping in `nocodb.js` service (`createCampaign`, `updateCampaign`)
- Add field normalization in `campaigns.js` controller
- Add checkbox/input in `admin.html` create and edit forms
- Add form handling in `admin.js` (read/write form data)
- Implement frontend logic in `campaign.js` based on setting value
- Update `README.MD` and `files-explainer.md` with new setting documentation
**Example: Accessing settings in frontend:**
```javascript
// In campaign.js after loading campaign data
if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
document.getElementById('email-count').textContent = this.campaign.emailCount;
document.getElementById('campaign-stats').style.display = 'block';
}
```
**Example: Setting default values in backend:**
```javascript
// In campaigns.js createCampaign()
const campaignData = {
show_email_count: req.body.show_email_count ?? true, // Default ON
allow_email_editing: req.body.allow_email_editing ?? false, // Default OFF
// ... other fields
};
```
## Key Principles

View File

@@ -1053,6 +1053,12 @@ create_campaigns_table() {
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "show_call_count",
"title": "Show Call Count",
"uidt": "Checkbox",
"cdf": "true"
},
{
"column_name": "allow_email_editing",
"title": "Allow Email Editing",
@@ -1217,6 +1223,91 @@ create_campaign_emails_table() {
create_table "$base_id" "influence_campaign_emails" "$table_data" "Campaign email tracking"
}
# Function to create the call logs table
create_call_logs_table() {
local base_id=$1
local table_data='{
"table_name": "influence_call_logs",
"title": "Influence Call Logs",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID"
},
{
"column_name": "representative_name",
"title": "Representative Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "representative_title",
"title": "Representative Title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "phone_number",
"title": "Phone Number",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "office_type",
"title": "Office Type",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "caller_name",
"title": "Caller Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "caller_email",
"title": "Caller Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "postal_code",
"title": "Postal Code",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "campaign_id",
"title": "Campaign ID",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "campaign_slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "caller_ip",
"title": "Caller IP",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "called_at",
"title": "Called At",
"uidt": "DateTime",
"rqd": false
}
]
}'
create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
}
# Function to create the users table
create_users_table() {
local base_id="$1"
@@ -1337,6 +1428,7 @@ update_env_with_table_ids() {
local campaigns_table_id=$5
local campaign_emails_table_id=$6
local users_table_id=$7
local call_logs_table_id=$8
print_status "Updating .env file with NocoDB project and table IDs..."
@@ -1371,6 +1463,7 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_CAMPAIGNS" "$campaigns_table_id"
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
print_success "Successfully updated .env file with all table IDs"
@@ -1384,6 +1477,7 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CAMPAIGNS=$campaigns_table_id"
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
print_status "NOCODB_TABLE_USERS=$users_table_id"
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
}
@@ -1484,8 +1578,15 @@ main() {
exit 1
fi
# Create call logs table
CALL_LOGS_TABLE_ID=$(create_call_logs_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create call logs table"
exit 1
fi
# Validate all table IDs were created successfully
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"; then
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
@@ -1506,6 +1607,7 @@ main() {
table_mapping["influence_campaigns"]="$CAMPAIGNS_TABLE_ID"
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
table_mapping["influence_users"]="$USERS_TABLE_ID"
table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
# Get source table information
local source_tables_response
@@ -1557,6 +1659,7 @@ main() {
print_status " - influence_campaigns (ID: $CAMPAIGNS_TABLE_ID)"
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
print_status " - influence_users (ID: $USERS_TABLE_ID)"
print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
# Automatically update .env file with new project ID
print_status ""
@@ -1579,7 +1682,7 @@ main() {
fi
# Update .env file with table IDs
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"
print_status ""
print_status "============================================================"