diff --git a/influence/README.MD b/influence/README.MD index 31e9a89..618195c 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -221,6 +221,7 @@ RATE_LIMIT_MAX_REQUESTS=100 - **👤 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 + - **⭐ Highlight Campaign**: Feature this campaign on the homepage (replaces postal code search) - **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board 7. **Set Campaign Status**: @@ -248,6 +249,37 @@ The homepage automatically displays all active campaigns in a responsive grid be - Tablet: 2 columns - Mobile: 1 column - **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 - **Security**: HTML content is escaped to prevent XSS attacks - **Sorting**: Campaigns display newest first by creation date diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index 2d30d71..48336b7 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -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) { diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index c94d446..59860b1 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -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, +
+ + +
@@ -1152,6 +1175,10 @@ Sincerely, +
+ + +
diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css index 57d6f61..59ada5d 100644 --- a/influence/app/public/css/styles.css +++ b/influence/app/public/css/styles.css @@ -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; + } +} diff --git a/influence/app/public/index.html b/influence/app/public/index.html index 442f34f..ff50e3a 100644 --- a/influence/app/public/index.html +++ b/influence/app/public/index.html @@ -72,6 +72,13 @@

Looking up your representatives...

+ + + diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js index 1bb44ca..fae0ccd 100644 --- a/influence/app/public/js/admin.js +++ b/influence/app/public/js/admin.js @@ -488,7 +488,8 @@ class AdminPanel { } listDiv.innerHTML = this.campaigns.map(campaign => ` -
+
+ ${campaign.highlight_campaign ? '
⭐ Highlighted
' : ''} ${campaign.cover_photo ? `
@@ -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')); diff --git a/influence/app/public/js/main.js b/influence/app/public/js/main.js index 6d14543..e55f325 100644 --- a/influence/app/public/js/main.js +++ b/influence/app/public/js/main.js @@ -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(`
📧${campaign.emailCount} Emails Sent
`); + } + if (campaign.show_call_count && campaign.callCount !== null) { + statsHTML.push(`
📞${campaign.callCount} Calls Made
`); + } + if (campaign.show_response_count && campaign.responseCount !== null) { + statsHTML.push(`
${campaign.responseCount} Responses
`); + } + + const highlightedHTML = ` +
+ ${campaign.cover_photo ? ` +
+
⭐ Featured Campaign
+

${this.escapeHtml(campaign.title || campaign.name)}

+
+ ` : ` +
⭐ Featured Campaign
+

${this.escapeHtml(campaign.title || campaign.name)}

+ `} + +
+ ${campaign.description ? `

${this.escapeHtml(campaign.description)}

` : ''} + + ${statsHTML.length > 0 ? ` +
+ ${statsHTML.join('')} +
+ ` : ''} + + +
+
+ `; + + // 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 diff --git a/influence/app/public/js/postal-lookup.js b/influence/app/public/js/postal-lookup.js index 0d94fbf..e1cee8a 100644 --- a/influence/app/public/js/postal-lookup.js +++ b/influence/app/public/js/postal-lookup.js @@ -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)); } diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js index 6616e5a..6ba942b 100644 --- a/influence/app/routes/api.js +++ b/influence/app/routes/api.js @@ -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); diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js index 0041c8d..edc8d17 100644 --- a/influence/app/services/nocodb.js +++ b/influence/app/services/nocodb.js @@ -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 { diff --git a/influence/files-explainer.md b/influence/files-explainer.md index 1ea99b4..6bb6dc4 100644 --- a/influence/files-explainer.md +++ b/influence/files-explainer.md @@ -56,6 +56,27 @@ The application includes a flexible campaign configuration system that allows ad - Paused: Temporarily disabled - 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 **Database Schema** (`build-nocodb.sh`): diff --git a/influence/instruct.md b/influence/instruct.md index e374ded..98c3b05 100644 --- a/influence/instruct.md +++ b/influence/instruct.md @@ -54,7 +54,16 @@ The application supports flexible campaign configuration through the admin panel - Federal, Provincial, Municipal, School Board - 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 - Active: Visible on main page - Paused: Temporarily disabled diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh index 2f039d5..7905f46 100755 --- a/influence/scripts/build-nocodb.sh +++ b/influence/scripts/build-nocodb.sh @@ -1093,6 +1093,12 @@ create_campaigns_table() { "uidt": "Checkbox", "cdf": "false" }, + { + "column_name": "highlight_campaign", + "title": "Highlight Campaign", + "uidt": "Checkbox", + "cdf": "false" + }, { "column_name": "target_government_levels", "title": "Target Government Levels",