feat(campaigns): add highlighted campaign feature with admin controls and UI updates

This commit is contained in:
admin 2025-11-07 10:15:41 -07:00
parent 1bdc2b9ae0
commit 4ef4ac414b
13 changed files with 601 additions and 5 deletions

View File

@ -221,6 +221,7 @@ RATE_LIMIT_MAX_REQUESTS=100
- **👤 Collect User Info**: Request user name and email - **👤 Collect User Info**: Request user name and email
- **📊 Show Email Count**: Display total emails sent (engagement metric) - **📊 Show Email Count**: Display total emails sent (engagement metric)
- **✏️ Allow Email Editing**: Let users customize email template - **✏️ Allow Email Editing**: Let users customize email template
- **⭐ Highlight Campaign**: Feature this campaign on the homepage (replaces postal code search)
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board - **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
7. **Set Campaign Status**: 7. **Set Campaign Status**:
@ -248,6 +249,37 @@ The homepage automatically displays all active campaigns in a responsive grid be
- Tablet: 2 columns - Tablet: 2 columns
- Mobile: 1 column - Mobile: 1 column
- **Click Navigation**: Users can click any campaign card to visit the full campaign page - **Click Navigation**: Users can click any campaign card to visit the full campaign page
### Highlighted Campaign Feature
Promote a priority campaign by highlighting it on the homepage, replacing the postal code search section with featured campaign information.
**How to Highlight a Campaign**:
1. Navigate to Admin Panel → Edit Campaign
2. Select the campaign you want to feature
3. Check the "⭐ Highlight Campaign" checkbox
4. Save changes
**Highlighted Campaign Display**:
- **Homepage Takeover**: Replaces postal code search with campaign showcase
- **Featured Badge**: Shows "⭐ Featured Campaign" badge
- **Campaign Details**: Displays title, description, and engagement stats
- **Primary CTA**: Large "Join This Campaign" button
- **Fallback Option**: "Find Representatives by Postal Code" button for users who want standard lookup
- **Visual Indicators**: Gold border and badge in admin panel campaign list
**Important Notes**:
- **One at a Time**: Only ONE campaign can be highlighted simultaneously
- **Auto-Unset**: Setting a new highlighted campaign automatically removes highlighting from previous campaign
- **Requires Active Status**: Campaign must have status="active" to be highlighted
- **Admin Control**: Only administrators can set highlighted campaigns
**Technical Implementation**:
- Backend validates and ensures single highlighted campaign via `setHighlightedCampaign()`
- Frontend checks `/public/highlighted-campaign` API on page load
- Postal code lookup remains accessible via button click
- Highlighting state persists across page reloads
- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist - **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 - **Security**: HTML content is escaped to prevent XSS attacks
- **Sorting**: Campaigns display newest first by creation date - **Sorting**: Campaigns display newest first by creation date

View File

@ -176,6 +176,75 @@ class CampaignsController {
} }
} }
// Get the currently highlighted campaign (public)
async getHighlightedCampaign(req, res, next) {
try {
const campaign = await nocoDB.getHighlightedCampaign();
if (!campaign) {
return res.json({
success: true,
campaign: null
});
}
const id = campaign.ID || campaign.Id || campaign.id;
// Get email count if show_email_count is enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
if (showEmailCount && id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
}
// Get call count if show_call_count is enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
if (showCallCount && id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
}
// Get verified response count
let verifiedResponseCount = 0;
if (id != null) {
verifiedResponseCount = await nocoDB.getCampaignVerifiedResponseCount(id);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Return only public-facing information
const highlightedCampaign = {
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,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount,
verifiedResponseCount
};
res.json({
success: true,
campaign: highlightedCampaign
});
} catch (error) {
console.error('Get highlighted campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve highlighted campaign',
message: error.message
});
}
}
// Get all campaigns (for admin panel) // Get all campaigns (for admin panel)
async getAllCampaigns(req, res, next) { async getAllCampaigns(req, res, next) {
try { try {
@ -210,6 +279,7 @@ class CampaignsController {
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients, allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
highlight_campaign: campaign['Highlight Campaign'] || campaign.highlight_campaign || false,
target_government_levels: normalizedTargetLevels, target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at, updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -587,7 +657,8 @@ class CampaignsController {
'show_email_count', 'show_email_count',
'allow_email_editing', 'allow_email_editing',
'show_response_wall', 'show_response_wall',
'allow_custom_recipients' 'allow_custom_recipients',
'highlight_campaign'
]; ];
booleanFields.forEach(field => { booleanFields.forEach(field => {
@ -600,6 +671,17 @@ class CampaignsController {
} }
}); });
// Handle highlight_campaign special logic
// If this campaign is being set to highlighted, unset all others
if (updates.highlight_campaign === true) {
await nocoDB.setHighlightedCampaign(id);
// Remove from updates since we already handled it
delete updates.highlight_campaign;
} else if (updates.highlight_campaign === false) {
// Just unset this one
// Keep in updates to let the normal update flow handle it
}
console.log('Updates object before saving:', updates); console.log('Updates object before saving:', updates);
if (updates.status !== undefined) { if (updates.status !== undefined) {

View File

@ -106,6 +106,25 @@
box-shadow: 0 4px 8px rgba(0,0,0,0.15); box-shadow: 0 4px 8px rgba(0,0,0,0.15);
} }
.campaign-card.highlighted {
border: 2px solid #ffd700;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
}
.campaign-highlight-badge {
position: absolute;
top: 10px;
right: 10px;
background: #ffd700;
color: #333;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: bold;
z-index: 10;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.campaign-card-cover { .campaign-card-cover {
background-size: cover; background-size: cover;
background-position: center; background-position: center;
@ -1018,6 +1037,10 @@ Sincerely,
<input type="checkbox" id="create-show-response-wall" name="show_response_wall"> <input type="checkbox" id="create-show-response-wall" name="show_response_wall">
<label for="create-show-response-wall">💬 Show Response Wall Button</label> <label for="create-show-response-wall">💬 Show Response Wall Button</label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="create-highlight-campaign" name="highlight_campaign">
<label for="create-highlight-campaign">⭐ Highlight on Homepage (replaces postal code search)</label>
</div>
</div> </div>
</div> </div>
@ -1152,6 +1175,10 @@ Sincerely,
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall"> <input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
<label for="edit-show-response-wall">💬 Show Response Wall Button</label> <label for="edit-show-response-wall">💬 Show Response Wall Button</label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="edit-highlight-campaign" name="highlight_campaign">
<label for="edit-highlight-campaign">⭐ Highlight on Homepage (replaces postal code search)</label>
</div>
</div> </div>
</div> </div>

View File

@ -170,6 +170,7 @@ header {
box-shadow: 0 8px 24px rgba(0, 90, 156, 0.2); box-shadow: 0 8px 24px rgba(0, 90, 156, 0.2);
overflow: hidden; overflow: hidden;
text-align: center; text-align: center;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
} }
@keyframes gradientShift { @keyframes gradientShift {
@ -2425,3 +2426,231 @@ footer a:hover {
#share-email { #share-email {
background: #ea4335; background: #ea4335;
} }
/* Highlighted Campaign Styles */
.highlighted-campaign-container {
background: rgba(255, 255, 255, 0.95);
padding: 0;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
margin-bottom: 0;
max-width: 700px;
margin-left: auto;
margin-right: auto;
position: relative;
z-index: 1;
text-align: center;
transform: translateY(30px);
transition: transform 1.5s cubic-bezier(0.4, 0, 0.2, 1) 0.5s,
opacity 1.5s ease-out 0.5s;
border: 1px solid rgba(255, 255, 255, 0.3);
opacity: 0;
overflow: hidden;
}
.highlighted-campaign-container.visible {
transform: translateY(0);
opacity: 1;
}
.highlighted-campaign-header {
position: relative;
padding: 50px 30px;
border-radius: 12px 12px 0 0;
min-height: 200px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.highlighted-campaign-header .highlighted-campaign-badge {
background: rgba(255, 215, 0, 0.95);
margin-bottom: 15px;
backdrop-filter: blur(8px);
}
.highlighted-campaign-header h2 {
color: #ffffff !important;
text-shadow: 2px 2px 12px rgba(0, 0, 0, 0.8);
margin: 0;
font-size: 2.2rem;
font-weight: bold;
background: rgba(0, 0, 0, 0.75);
padding: 15px 25px;
border-radius: 8px;
backdrop-filter: blur(10px);
display: inline-block;
line-height: 1.3;
}
.highlighted-campaign-content {
padding: 35px 30px;
}
.highlighted-campaign-badge {
display: inline-block;
background: linear-gradient(135deg, #ffd700 0%, #ffed4e 100%);
color: #333;
padding: 8px 20px;
border-radius: 25px;
font-weight: bold;
font-size: 0.9rem;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(255, 215, 0, 0.3);
}
.highlighted-campaign-container h2 {
color: #1a1a1a;
margin: 0 0 15px 0;
font-size: 2rem;
}
.highlighted-campaign-container .campaign-description {
color: #666;
font-size: 1.1rem;
line-height: 1.6;
margin-bottom: 25px;
}
.campaign-stats-inline {
display: flex;
justify-content: center;
gap: 25px;
margin: 30px 0;
flex-wrap: wrap;
}
.campaign-stats-inline .stat {
display: flex;
flex-direction: column;
align-items: center;
font-size: 0.95rem;
color: #555;
gap: 5px;
}
.campaign-stats-inline .stat .stat-icon {
font-size: 1.8rem;
margin-bottom: 5px;
}
.campaign-stats-inline .stat strong {
color: #005a9c;
font-size: 1.5rem;
display: block;
font-weight: bold;
}
.campaign-cta {
margin: 30px 0 20px 0;
}
.btn-large {
padding: 15px 40px;
font-size: 1.1rem;
font-weight: bold;
}
.or-divider {
position: relative;
text-align: center;
margin: 25px 0;
}
.or-divider::before {
content: '';
position: absolute;
top: 50%;
left: 0;
right: 0;
height: 1px;
background: #ddd;
z-index: 0;
}
.or-divider span {
background: white;
padding: 0 20px;
position: relative;
z-index: 1;
color: #999;
font-size: 0.9rem;
}
/* Loading and fade-in animations */
#postal-input-section {
transition: opacity 0.4s ease-in-out;
}
/* Highlighted Campaign Section Styles (inside blue background) */
.highlighted-campaign-section {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition: grid-template-rows 2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 1.8s ease-in 0.2s,
margin 2s cubic-bezier(0.4, 0, 0.2, 1),
padding 2s cubic-bezier(0.4, 0, 0.2, 1);
margin-top: 0;
padding-top: 0;
}
.highlighted-campaign-section > div {
overflow: hidden;
}
.highlighted-campaign-section.show {
grid-template-rows: 1fr;
opacity: 1;
margin-top: 40px;
padding-top: 30px;
border-top: 2px solid rgba(255, 255, 255, 0.2);
}
.fade-in-smooth {
animation: fadeInSmooth 1.5s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes fadeInSmooth {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 768px) {
.highlighted-campaign-container {
max-width: 100%;
}
.highlighted-campaign-header {
padding: 40px 20px;
min-height: 180px;
}
.highlighted-campaign-header h2 {
font-size: 1.6rem;
}
.highlighted-campaign-content {
padding: 25px 20px;
}
.highlighted-campaign-container h2 {
font-size: 1.5rem;
}
.campaign-stats-inline {
gap: 20px;
}
.btn-large {
padding: 12px 30px;
font-size: 1rem;
}
}

View File

@ -72,6 +72,13 @@
<p>Looking up your representatives...</p> <p>Looking up your representatives...</p>
</div> </div>
</div> </div>
<!-- Highlighted Campaign Section (inside blue background) -->
<div id="highlighted-campaign-section" class="highlighted-campaign-section" style="display: none;">
<div id="highlighted-campaign-container">
<!-- Highlighted campaign will be dynamically inserted here -->
</div>
</div>
</section> </section>
<!-- Representatives Display Section --> <!-- Representatives Display Section -->

View File

@ -488,7 +488,8 @@ class AdminPanel {
} }
listDiv.innerHTML = this.campaigns.map(campaign => ` listDiv.innerHTML = this.campaigns.map(campaign => `
<div class="campaign-card" data-campaign-id="${campaign.id}"> <div class="campaign-card ${campaign.highlight_campaign ? 'highlighted' : ''}" data-campaign-id="${campaign.id}">
${campaign.highlight_campaign ? '<div class="campaign-highlight-badge">⭐ Highlighted</div>' : ''}
${campaign.cover_photo ? ` ${campaign.cover_photo ? `
<div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');"> <div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');">
<div class="campaign-card-cover-overlay"> <div class="campaign-card-cover-overlay">
@ -578,6 +579,7 @@ class AdminPanel {
campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
campaignFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on'); campaignFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
campaignFormData.append('highlight_campaign', formData.get('highlight_campaign') === 'on');
// Handle target_government_levels array // Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels')); const targetLevels = Array.from(formData.getAll('target_government_levels'));
@ -651,6 +653,7 @@ class AdminPanel {
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing; form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall; form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall;
form.querySelector('[name="allow_custom_recipients"]').checked = campaign.allow_custom_recipients || false; form.querySelector('[name="allow_custom_recipients"]').checked = campaign.allow_custom_recipients || false;
form.querySelector('[name="highlight_campaign"]').checked = campaign.highlight_campaign || false;
// Show/hide custom recipients section based on checkbox // Show/hide custom recipients section based on checkbox
this.toggleCustomRecipientsSection(campaign.allow_custom_recipients); this.toggleCustomRecipientsSection(campaign.allow_custom_recipients);
@ -710,6 +713,7 @@ class AdminPanel {
updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
updateFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on'); updateFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
updateFormData.append('highlight_campaign', formData.get('highlight_campaign') === 'on');
// Handle target_government_levels array // Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels')); const targetLevels = Array.from(formData.getAll('target_government_levels'));

View File

@ -4,13 +4,23 @@ class MainApp {
this.init(); this.init();
} }
init() { async init() {
// Initialize message display system // Initialize message display system
window.messageDisplay = new MessageDisplay(); window.messageDisplay = new MessageDisplay();
// Check API health on startup // Check API health on startup
this.checkAPIHealth(); this.checkAPIHealth();
// Initialize postal lookup immediately (always show it first)
this.postalLookup = new PostalLookup(this.updateRepresentatives.bind(this));
// Check for highlighted campaign FIRST (before campaigns grid)
await this.checkHighlightedCampaign();
// Initialize campaigns grid AFTER highlighted campaign loads
this.campaignsGrid = new CampaignsGrid();
await this.campaignsGrid.init();
// Add global error handling // Add global error handling
window.addEventListener('error', (e) => { window.addEventListener('error', (e) => {
// Only log and show message for actual errors, not null/undefined // Only log and show message for actual errors, not null/undefined
@ -57,6 +67,119 @@ class MainApp {
window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error'); window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error');
} }
} }
async checkHighlightedCampaign() {
try {
const response = await fetch('/api/public/highlighted-campaign');
if (!response.ok) {
if (response.status === 404) {
// No highlighted campaign, show normal postal code lookup
return false;
}
throw new Error('Failed to fetch highlighted campaign');
}
const data = await response.json();
if (data.success && data.campaign) {
this.displayHighlightedCampaign(data.campaign);
return true;
}
return false;
} catch (error) {
console.error('Error checking for highlighted campaign:', error);
// Continue with normal postal code lookup if there's an error
return false;
}
}
displayHighlightedCampaign(campaign) {
const highlightedSection = document.getElementById('highlighted-campaign-section');
const highlightedContainer = document.getElementById('highlighted-campaign-container');
if (!highlightedSection || !highlightedContainer) return;
// Build the campaign display HTML with cover photo
const coverPhotoStyle = campaign.cover_photo
? `background-image: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
: 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);';
const statsHTML = [];
if (campaign.show_email_count && campaign.emailCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📧</span><strong>${campaign.emailCount}</strong> Emails Sent</div>`);
}
if (campaign.show_call_count && campaign.callCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📞</span><strong>${campaign.callCount}</strong> Calls Made</div>`);
}
if (campaign.show_response_count && campaign.responseCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">✅</span><strong>${campaign.responseCount}</strong> Responses</div>`);
}
const highlightedHTML = `
<div class="highlighted-campaign-container">
${campaign.cover_photo ? `
<div class="highlighted-campaign-header" style="${coverPhotoStyle}">
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
</div>
` : `
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
`}
<div class="highlighted-campaign-content">
${campaign.description ? `<p class="campaign-description">${this.escapeHtml(campaign.description)}</p>` : ''}
${statsHTML.length > 0 ? `
<div class="campaign-stats-inline">
${statsHTML.join('')}
</div>
` : ''}
<div class="campaign-cta">
<a href="/campaign/${campaign.slug}" class="btn btn-primary btn-large">
Join This Campaign
</a>
</div>
</div>
</div>
`;
// Insert the HTML
highlightedContainer.innerHTML = highlightedHTML;
// Make section visible but collapsed
highlightedSection.style.display = 'grid';
// Force a reflow to ensure the initial state is applied
const height = highlightedSection.offsetHeight;
console.log('Campaign section initial height:', height);
// Wait a bit longer before starting animation to ensure it's visible
setTimeout(() => {
console.log('Starting campaign expansion animation...');
highlightedSection.classList.add('show');
// Add animation to the container after expansion starts
setTimeout(() => {
const container = highlightedContainer.querySelector('.highlighted-campaign-container');
if (container) {
console.log('Adding visible class to container...');
container.classList.add('visible', 'fade-in-smooth');
}
}, 300);
}, 100);
}
updateRepresentatives(representatives) {
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
} }
// Message Display System // Message Display System

View File

@ -15,7 +15,12 @@ class PostalLookup {
init() { init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e)); this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Only add refresh button listener if it exists
if (this.refreshBtn) {
this.refreshBtn.addEventListener('click', () => this.handleRefresh()); this.refreshBtn.addEventListener('click', () => this.handleRefresh());
}
this.input.addEventListener('input', (e) => this.formatPostalCode(e)); this.input.addEventListener('input', (e) => this.formatPostalCode(e));
} }

View File

@ -178,6 +178,7 @@ router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaig
// Campaign endpoints (Public) // Campaign endpoints (Public)
router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns); router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
router.get('/public/highlighted-campaign', rateLimiter.general, campaignsController.getHighlightedCampaign);
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug); router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/qrcode', rateLimiter.general, campaignsController.generateQRCode); router.get('/campaigns/:slug/qrcode', rateLimiter.general, campaignsController.generateQRCode);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign); router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);

View File

@ -440,6 +440,7 @@ class NocoDBService {
'Allow Email Editing': campaignData.allow_email_editing, 'Allow Email Editing': campaignData.allow_email_editing,
'Allow Custom Recipients': campaignData.allow_custom_recipients, 'Allow Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall, 'Show Response Wall Button': campaignData.show_response_wall,
'Highlight Campaign': campaignData.highlight_campaign,
'Target Government Levels': campaignData.target_government_levels, 'Target Government Levels': campaignData.target_government_levels,
'Created By User ID': campaignData.created_by_user_id, 'Created By User ID': campaignData.created_by_user_id,
'Created By User Email': campaignData.created_by_user_email, 'Created By User Email': campaignData.created_by_user_email,
@ -474,6 +475,7 @@ class NocoDBService {
if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing; if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing;
if (updates.allow_custom_recipients !== undefined) mappedUpdates['Allow Custom Recipients'] = updates.allow_custom_recipients; if (updates.allow_custom_recipients !== undefined) mappedUpdates['Allow Custom Recipients'] = updates.allow_custom_recipients;
if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall; if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall;
if (updates.highlight_campaign !== undefined) mappedUpdates['Highlight Campaign'] = updates.highlight_campaign;
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels; if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at; if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
@ -497,6 +499,54 @@ class NocoDBService {
} }
} }
// Get the currently highlighted campaign
async getHighlightedCampaign() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(Highlight Campaign,eq,true)`,
limit: 1
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get highlighted campaign failed:', error);
throw error;
}
}
// Set a campaign as highlighted (and unset all others)
async setHighlightedCampaign(campaignId) {
try {
// First, unset any existing highlighted campaigns
const currentHighlighted = await this.getHighlightedCampaign();
if (currentHighlighted) {
const currentId = currentHighlighted.ID || currentHighlighted.Id || currentHighlighted.id;
if (currentId && currentId !== campaignId) {
await this.updateCampaign(currentId, { highlight_campaign: false });
}
}
// Then set the new highlighted campaign
await this.updateCampaign(campaignId, { highlight_campaign: true });
return { success: true };
} catch (error) {
console.error('Set highlighted campaign failed:', error);
throw error;
}
}
// Unset highlighted campaign
async unsetHighlightedCampaign(campaignId) {
try {
await this.updateCampaign(campaignId, { highlight_campaign: false });
return { success: true };
} catch (error) {
console.error('Unset highlighted campaign failed:', error);
throw error;
}
}
// Campaign email tracking methods // Campaign email tracking methods
async logCampaignEmail(emailData) { async logCampaignEmail(emailData) {
try { try {

View File

@ -56,6 +56,27 @@ The application includes a flexible campaign configuration system that allows ad
- Paused: Temporarily disabled - Paused: Temporarily disabled
- Archived: Completed campaigns, read-only - Archived: Completed campaigns, read-only
**Highlight Campaign** (`highlight_campaign`):
- **Purpose**: Features a single priority campaign on the homepage by replacing the postal code search section
- **Implementation**: Checkbox field in campaigns table (default: false)
- **When Enabled**: Campaign takes over homepage hero section with featured display
- **Exclusivity**: Only ONE campaign can be highlighted at a time
- **Use Cases**:
- ✅ Promote urgent or high-priority campaigns
- ✅ Drive maximum participation for time-sensitive issues
- ✅ Showcase flagship campaigns to new visitors
- ❌ Don't use for evergreen or background campaigns
- **Database**: `highlight_campaign` BOOLEAN field in campaigns table
- **Backend**: `setHighlightedCampaign()` ensures single highlighted campaign by unsetting others
- **Frontend**: `main.js` checks `/public/highlighted-campaign` on page load and replaces UI
- **Admin Panel**:
- Checkbox labeled "⭐ Highlight on Homepage" in create/edit forms
- Gold badge and border on highlighted campaign in campaign list
- Visual indicator: "⭐ Highlighted" badge on campaign card
- **API Endpoints**:
- `GET /public/highlighted-campaign`: Returns current highlighted campaign or null
- Backend validates only one campaign is highlighted via database logic
### Technical Implementation ### Technical Implementation
**Database Schema** (`build-nocodb.sh`): **Database Schema** (`build-nocodb.sh`):

View File

@ -54,7 +54,16 @@ The application supports flexible campaign configuration through the admin panel
- Federal, Provincial, Municipal, School Board - Federal, Provincial, Municipal, School Board
- Filters which representatives are shown - Filters which representatives are shown
8. **Campaign Status** (`status`) - **Required** 8. **Highlight Campaign** (`highlight_campaign`) - **Default: OFF**
- Displays the campaign prominently on the homepage
- Replaces the postal code search section with campaign information
- Only ONE campaign can be highlighted at a time
- **Database**: Boolean field in campaigns table
- **Backend**: `setHighlightedCampaign()` ensures only one campaign is highlighted
- **Frontend**: `main.js` checks on page load and replaces postal code section
- **Admin Panel**: Shows ⭐ badge on highlighted campaign card
9. **Campaign Status** (`status`) - **Required**
- Draft: Testing only, hidden from public - Draft: Testing only, hidden from public
- Active: Visible on main page - Active: Visible on main page
- Paused: Temporarily disabled - Paused: Temporarily disabled

View File

@ -1093,6 +1093,12 @@ create_campaigns_table() {
"uidt": "Checkbox", "uidt": "Checkbox",
"cdf": "false" "cdf": "false"
}, },
{
"column_name": "highlight_campaign",
"title": "Highlight Campaign",
"uidt": "Checkbox",
"cdf": "false"
},
{ {
"column_name": "target_government_levels", "column_name": "target_government_levels",
"title": "Target Government Levels", "title": "Target Government Levels",