feat(campaigns): add highlighted campaign feature with admin controls and UI updates
This commit is contained in:
parent
1bdc2b9ae0
commit
4ef4ac414b
@ -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
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 -->
|
||||||
|
|||||||
@ -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'));
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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`):
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user