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

This commit is contained in:
2025-11-07 10:15:41 -07:00
parent 4bbf772061
commit 564685abed
13 changed files with 601 additions and 5 deletions

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)
async getAllCampaigns(req, res, next) {
try {
@@ -210,6 +279,7 @@ class CampaignsController {
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
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,
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@@ -587,7 +657,8 @@ class CampaignsController {
'show_email_count',
'allow_email_editing',
'show_response_wall',
'allow_custom_recipients'
'allow_custom_recipients',
'highlight_campaign'
];
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);
if (updates.status !== undefined) {

View File

@@ -106,6 +106,25 @@
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 {
background-size: cover;
background-position: center;
@@ -1018,6 +1037,10 @@ Sincerely,
<input type="checkbox" id="create-show-response-wall" name="show_response_wall">
<label for="create-show-response-wall">💬 Show Response Wall Button</label>
</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>
@@ -1152,6 +1175,10 @@ Sincerely,
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
<label for="edit-show-response-wall">💬 Show Response Wall Button</label>
</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>

View File

@@ -170,6 +170,7 @@ header {
box-shadow: 0 8px 24px rgba(0, 90, 156, 0.2);
overflow: hidden;
text-align: center;
transition: all 0.8s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes gradientShift {
@@ -2425,3 +2426,231 @@ footer a:hover {
#share-email {
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>
</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>
<!-- Representatives Display Section -->

View File

@@ -488,7 +488,8 @@ class AdminPanel {
}
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 ? `
<div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');">
<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('show_response_wall', formData.get('show_response_wall') === '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
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="show_response_wall"]').checked = campaign.show_response_wall;
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
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('show_response_wall', formData.get('show_response_wall') === '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
const targetLevels = Array.from(formData.getAll('target_government_levels'));

View File

@@ -4,13 +4,23 @@ class MainApp {
this.init();
}
init() {
async init() {
// Initialize message display system
window.messageDisplay = new MessageDisplay();
// Check API health on startup
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
window.addEventListener('error', (e) => {
// 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');
}
}
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

View File

@@ -15,7 +15,12 @@ class PostalLookup {
init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
// Only add refresh button listener if it exists
if (this.refreshBtn) {
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
}
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)
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/qrcode', rateLimiter.general, campaignsController.generateQRCode);
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 Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall,
'Highlight Campaign': campaignData.highlight_campaign,
'Target Government Levels': campaignData.target_government_levels,
'Created By User ID': campaignData.created_by_user_id,
'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_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.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.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
async logCampaignEmail(emailData) {
try {