add verify button to the response wall and qr code generation

This commit is contained in:
admin 2025-10-17 11:30:26 -06:00
parent 8372b8a4bd
commit 4b5e2249dd
13 changed files with 1102 additions and 3 deletions

View File

@ -434,6 +434,36 @@ The Response Wall creates transparency and accountability by allowing campaign p
5. Admins can mark verified responses with special badge 5. Admins can mark verified responses with special badge
6. Community upvotes highlight most impactful responses 6. Community upvotes highlight most impactful responses
### QR Code Sharing Feature
The application includes dynamic QR code generation for easy campaign and response wall sharing.
**Key Features:**
- **Campaign QR Codes**: Generate scannable QR codes for campaign pages
- **Response Wall QR Codes**: Share response walls with QR codes for mobile scanning
- **High-Quality Generation**: 400x400px PNG images with high error correction (level H)
- **Download Support**: One-click download of QR code images for printing or sharing
- **Social Integration**: QR code button alongside social share buttons (Facebook, Twitter, LinkedIn, etc.)
- **Caching**: QR codes cached for 1 hour to improve performance
**How to Use:**
1. Visit any campaign page or response wall
2. Click the QR code icon (📱) in the social share buttons section
3. A modal appears with the generated QR code
4. Scan with any smartphone camera to visit the page
5. Click "Download QR Code" to save the image for printing or sharing
**Technical Implementation:**
- Backend endpoint: `GET /api/campaigns/:slug/qrcode?type=campaign|response-wall`
- Uses `qrcode` npm package for generation
- Proper MIME type and cache headers
- Modal UI with download functionality
**Use Cases:**
- Print QR codes on flyers and posters for offline campaign promotion
- Share QR codes in presentations and meetings
- Include in email newsletters for mobile-friendly access
- Display at events for easy sign-up
### Email Integration ### Email Integration
- Modal-based email composer - Modal-based email composer
- Pre-filled recipient information - Pre-filled recipient information

View File

@ -1,6 +1,7 @@
const nocoDB = require('../services/nocodb'); const nocoDB = require('../services/nocodb');
const emailService = require('../services/email'); const emailService = require('../services/email');
const representAPI = require('../services/represent-api'); const representAPI = require('../services/represent-api');
const qrcodeService = require('../services/qrcode');
const { generateSlug, validateSlug } = require('../utils/validators'); const { generateSlug, validateSlug } = require('../utils/validators');
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
@ -1074,6 +1075,66 @@ class CampaignsController {
}); });
} }
} }
// Generate QR code for campaign or response wall
async generateQRCode(req, res, next) {
try {
const { slug } = req.params;
const { type } = req.query; // 'campaign' or 'response-wall'
// Validate type parameter
if (type && !['campaign', 'response-wall'].includes(type)) {
return res.status(400).json({
success: false,
error: 'Invalid type parameter. Must be "campaign" or "response-wall"'
});
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Build URL based on type
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let targetUrl;
if (type === 'response-wall') {
targetUrl = `${appUrl}/response-wall.html?campaign=${slug}`;
} else {
// Default to campaign page
targetUrl = `${appUrl}/campaign/${slug}`;
}
// Generate QR code
const qrCodeBuffer = await qrcodeService.generateQRCode(targetUrl, {
width: 400,
margin: 2,
errorCorrectionLevel: 'H' // High error correction for better scanning
});
// Set response headers
res.set({
'Content-Type': 'image/png',
'Content-Length': qrCodeBuffer.length,
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
});
// Send the buffer
res.send(qrCodeBuffer);
} catch (error) {
console.error('Generate QR code error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate QR code',
message: error.message
});
}
}
} }
// Export controller instance and upload middleware // Export controller instance and upload middleware

View File

@ -913,6 +913,123 @@ async function reportResponse(req, res) {
} }
} }
/**
* Resend verification email for a response
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function resendVerification(req, res) {
try {
const { id } = req.params;
console.log('=== RESEND VERIFICATION REQUEST ===');
console.log('Response ID:', id);
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
console.log('Response not found for ID:', id);
return res.status(404).json({
success: false,
error: 'Response not found'
});
}
// Check if already verified
if (response.verified_at) {
return res.status(400).json({
success: false,
error: 'This response has already been verified'
});
}
// Check if we have the necessary data
if (!response.representative_email) {
return res.status(400).json({
success: false,
error: 'No representative email on file for this response'
});
}
if (!response.verification_token) {
// Generate a new token if one doesn't exist
const crypto = require('crypto');
const newToken = crypto.randomBytes(32).toString('hex');
const verificationSentAt = new Date().toISOString();
await nocodbService.updateRepresentativeResponse(id, {
verification_token: newToken,
verification_sent_at: verificationSentAt
});
response.verification_token = newToken;
response.verification_sent_at = verificationSentAt;
}
// Get campaign details
const campaign = await nocodbService.getCampaignBySlug(response.campaign_slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Send verification email
try {
const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
const verificationUrl = `${baseUrl}/api/responses/${response.id}/verify/${response.verification_token}`;
const reportUrl = `${baseUrl}/api/responses/${response.id}/report/${response.verification_token}`;
const campaignTitle = campaign.Title || campaign.title || 'Unknown Campaign';
const submittedDate = new Date(response.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
await emailService.sendResponseVerification({
representativeEmail: response.representative_email,
representativeName: response.representative_name,
campaignTitle,
responseType: response.response_type,
responseText: response.response_text,
submittedDate,
submitterName: response.is_anonymous ? 'Anonymous' : (response.submitted_by_name || 'A constituent'),
verificationUrl,
reportUrl
});
// Update verification_sent_at timestamp
await nocodbService.updateRepresentativeResponse(id, {
verification_sent_at: new Date().toISOString()
});
console.log('Verification email resent successfully to:', response.representative_email);
res.json({
success: true,
message: 'Verification email sent successfully to the representative'
});
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
res.status(500).json({
success: false,
error: 'Failed to send verification email. Please try again later.'
});
}
} catch (error) {
console.error('Error resending verification:', error);
res.status(500).json({
success: false,
error: 'An error occurred while processing your request'
});
}
}
module.exports = { module.exports = {
getCampaignResponses, getCampaignResponses,
submitResponse, submitResponse,
@ -924,5 +1041,6 @@ module.exports = {
updateResponse, updateResponse,
deleteResponse, deleteResponse,
verifyResponse, verifyResponse,
reportResponse reportResponse,
resendVerification
}; };

View File

@ -28,7 +28,8 @@
"nodemailer": "^6.9.4", "nodemailer": "^6.9.4",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1" "multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1", "nodemon": "^3.0.1",

View File

@ -493,6 +493,11 @@
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg> </svg>
</button> </button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
</button>
</div> </div>
</div> </div>
</div> </div>
@ -605,6 +610,19 @@
<div id="error-message" class="error-message" style="display: none;"></div> <div id="error-message" class="error-message" style="display: none;"></div>
</div> </div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Campaign</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Campaign QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this campaign page</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;"> <footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p> <p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a> | <a href="/index.html" id="home-link">Return to Main Page</a></small></p> <p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a> | <a href="/index.html" id="home-link">Return to Main Page</a></small></p>

View File

@ -95,6 +95,46 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }
/* Social Share Buttons in Header */
.share-buttons-header {
display: flex;
gap: 0.5rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
}
.share-btn-small {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.share-btn-small:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.share-btn-small svg {
width: 18px;
height: 18px;
fill: white;
}
.share-btn-small.copied {
background: rgba(40, 167, 69, 0.8);
border-color: rgba(40, 167, 69, 1);
}
.stats-banner { .stats-banner {
display: flex; display: flex;
justify-content: space-around; justify-content: space-around;
@ -324,6 +364,38 @@
font-weight: bold; font-weight: bold;
} }
/* Verify Button */
.verify-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
font-size: 0.9rem;
}
.verify-btn:hover {
background: #27ae60;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(39, 174, 96, 0.2);
}
.verify-btn .verify-icon {
font-size: 1.2rem;
font-weight: bold;
}
.verify-btn .verify-text {
white-space: nowrap;
}
/* Empty State */ /* Empty State */
.empty-state { .empty-state {
text-align: center; text-align: center;
@ -514,9 +586,124 @@
gap: 1rem; gap: 1rem;
align-items: flex-start; align-items: flex-start;
} }
.response-actions {
flex-direction: column;
width: 100%;
}
.verify-btn,
.upvote-btn {
width: 100%;
justify-content: center;
}
.modal-content { .modal-content {
margin: 10% 5%; margin: 10% 5%;
padding: 1rem; padding: 1rem;
} }
} }
/* QR Code Modal Styles */
.qrcode-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
animation: fadeIn 0.3s ease-in-out;
}
.qrcode-modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.qrcode-modal-content {
background-color: #fefefe;
margin: auto;
padding: 2rem;
border-radius: 12px;
max-width: 500px;
width: 90%;
position: relative;
animation: slideDown 0.3s ease-in-out;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.qrcode-close {
color: #aaa;
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.2s;
}
.qrcode-close:hover,
.qrcode-close:focus {
color: #000;
}
.qrcode-modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #2c3e50;
text-align: center;
font-size: 1.5rem;
}
.qrcode-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.qrcode-container img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.qrcode-instructions {
text-align: center;
color: #6c757d;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.qrcode-modal-content .btn {
width: 100%;
justify-content: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@ -2027,3 +2027,118 @@ footer a:hover {
padding: 16px; padding: 16px;
} }
} }
/* QR Code Modal Styles */
.qrcode-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
animation: fadeIn 0.3s ease-in-out;
}
.qrcode-modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.qrcode-modal-content {
background-color: #fefefe;
margin: auto;
padding: 2rem;
border-radius: 12px;
max-width: 500px;
width: 90%;
position: relative;
animation: slideDown 0.3s ease-in-out;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.qrcode-close {
color: #aaa;
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.2s;
}
.qrcode-close:hover,
.qrcode-close:focus {
color: #000;
}
.qrcode-modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #2c3e50;
text-align: center;
font-size: 1.5rem;
}
.qrcode-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.qrcode-container img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.qrcode-instructions {
text-align: center;
color: #6c757d;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.qrcode-modal-content .btn {
width: 100%;
justify-content: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@media (max-width: 768px) {
.qrcode-modal-content {
padding: 1.5rem;
max-width: 95%;
}
.qrcode-modal-content h2 {
font-size: 1.25rem;
}
}

View File

@ -105,6 +105,67 @@ class CampaignPage {
document.body.removeChild(textArea); document.body.removeChild(textArea);
} }
}); });
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
this.openQRCodeModal();
});
}
openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL
const qrcodeUrl = `/api/campaigns/${this.campaignSlug}/qrcode?type=campaign`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.campaignSlug}-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
} }
async loadCampaign() { async loadCampaign() {

View File

@ -334,6 +334,147 @@ function renderCampaignHeader() {
window.location.href = '/'; window.location.href = '/';
}); });
} }
// Set up social share buttons
setupShareButtons();
}
// Setup social share buttons
function setupShareButtons() {
const shareUrl = window.location.href;
// Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Twitter share
document.getElementById('share-twitter')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// LinkedIn share
document.getElementById('share-linkedin')?.addEventListener('click', () => {
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// WhatsApp share
document.getElementById('share-whatsapp')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank');
});
// Email share
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const body = currentCampaign ?
`I thought you might be interested in seeing what representatives are saying about this campaign:\n\n${currentCampaign.title}\n\n${shareUrl}` :
`Check out these representative responses:\n\n${shareUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
});
// Copy link
document.getElementById('share-copy')?.addEventListener('click', async () => {
const copyBtn = document.getElementById('share-copy');
try {
await navigator.clipboard.writeText(shareUrl);
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy link. Please copy manually: ' + shareUrl);
}
document.body.removeChild(textArea);
}
});
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
openQRCodeModal();
});
}
function openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL for response wall
const qrcodeUrl = `/api/campaigns/${currentCampaignSlug}/qrcode?type=response-wall`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentCampaignSlug}-response-wall-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
} }
// Load responses // Load responses
@ -433,6 +574,18 @@ function createResponseCard(response) {
} }
const upvoteClass = response.hasUpvoted ? 'upvoted' : ''; const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
// Add verify button HTML if response is unverified and has representative email
let verifyButtonHtml = '';
if (!response.is_verified && response.representative_email) {
// Show button if we have a representative email, regardless of whether verification was initially requested
verifyButtonHtml = `
<button class="verify-btn" data-response-id="${response.id}" data-verification-token="${escapeHtml(response.verification_token || '')}" data-rep-email="${escapeHtml(response.representative_email)}">
<span class="verify-icon">📧</span>
<span class="verify-text">Send Verification Email</span>
</button>
`;
}
card.innerHTML = ` card.innerHTML = `
<div class="response-header"> <div class="response-header">
@ -463,6 +616,7 @@ function createResponseCard(response) {
<span class="upvote-icon">👍</span> <span class="upvote-icon">👍</span>
<span class="upvote-count">${response.upvote_count || 0}</span> <span class="upvote-count">${response.upvote_count || 0}</span>
</button> </button>
${verifyButtonHtml}
</div> </div>
</div> </div>
`; `;
@ -472,6 +626,14 @@ function createResponseCard(response) {
upvoteBtn.addEventListener('click', function() { upvoteBtn.addEventListener('click', function() {
toggleUpvote(response.id, this); toggleUpvote(response.id, this);
}); });
// Add event listener for verify button if present
const verifyBtn = card.querySelector('.verify-btn');
if (verifyBtn) {
verifyBtn.addEventListener('click', function() {
handleVerifyClick(response.id, this.dataset.verificationToken, this.dataset.repEmail);
});
}
// Add event listener for screenshot image if present // Add event listener for screenshot image if present
const screenshotImg = card.querySelector('.screenshot-image'); const screenshotImg = card.querySelector('.screenshot-image');
@ -592,6 +754,99 @@ async function handleSubmitResponse(e) {
} }
} }
// Handle verify button click
async function handleVerifyClick(responseId, verificationToken, representativeEmail) {
// Mask email to show only first 3 characters and domain
// e.g., "john.doe@example.com" becomes "joh***@example.com"
const maskEmail = (email) => {
const [localPart, domain] = email.split('@');
if (localPart.length <= 3) {
return `${localPart}***@${domain}`;
}
return `${localPart.substring(0, 3)}***@${domain}`;
};
const maskedEmail = maskEmail(representativeEmail);
// Step 1: Prompt the representative to verify their identity by entering their email
const emailPrompt = prompt(
'To send a verification email, please enter the representative\'s email address.\n\n' +
'This email must match the representative email on file for this response.\n\n' +
`Email on file: ${maskedEmail}`,
''
);
// User cancelled
if (emailPrompt === null) {
return;
}
// Trim and lowercase for comparison
const enteredEmail = emailPrompt.trim().toLowerCase();
const storedEmail = representativeEmail.trim().toLowerCase();
// Check if email is empty
if (!enteredEmail) {
showError('Email address is required to send verification.');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(enteredEmail)) {
showError('Please enter a valid email address.');
return;
}
// Check if email matches
if (enteredEmail !== storedEmail) {
showError(
'The email you entered does not match the representative email on file.\n\n' +
`Expected: ${representativeEmail}\n` +
`You entered: ${emailPrompt.trim()}\n\n` +
'Verification email cannot be sent.'
);
return;
}
// Step 2: Email matches - confirm sending verification email
const confirmSend = confirm(
'Email verified! Ready to send verification email.\n\n' +
`A verification email will be sent to: ${representativeEmail}\n\n` +
'The representative will receive an email with a link to verify this response as authentic.\n\n' +
'Do you want to send the verification email?'
);
if (!confirmSend) {
return;
}
// Make request to resend verification email
try {
const response = await fetch(`/api/responses/${responseId}/resend-verification`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (response.ok && data.success) {
showSuccess(
'Verification email sent successfully!\n\n' +
`An email has been sent to ${representativeEmail} with a verification link.\n\n` +
'The representative must click the link in the email to complete verification.'
);
} else {
showError(data.error || 'Failed to send verification email. Please try again.');
}
} catch (error) {
console.error('Error sending verification email:', error);
showError('An error occurred while sending the verification email. Please try again.');
}
}
// View image in modal/new tab // View image in modal/new tab
function viewImage(url) { function viewImage(url) {
window.open(url, '_blank'); window.open(url, '_blank');

View File

@ -23,6 +23,45 @@
🏠 Home 🏠 Home
</button> </button>
</div> </div>
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
</svg>
</button>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
</svg>
</button>
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Share via Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg>
</button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg>
</button>
</div>
</div> </div>
</div> </div>
@ -203,6 +242,19 @@
</div> </div>
</div> </div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Response Wall</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Response Wall QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this response wall</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<script src="/js/api-client.js"></script> <script src="/js/api-client.js"></script>
<script src="/js/response-wall.js"></script> <script src="/js/response-wall.js"></script>
</body> </body>

View File

@ -175,6 +175,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('/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/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign); router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post( router.post(
'/campaigns/:slug/track-user', '/campaigns/:slug/track-user',
@ -248,6 +249,7 @@ router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, respon
// Response Verification Routes (public - no auth required) // Response Verification Routes (public - no auth required)
router.get('/responses/:id/verify/:token', responsesController.verifyResponse); router.get('/responses/:id/verify/:token', responsesController.verifyResponse);
router.get('/responses/:id/report/:token', responsesController.reportResponse); router.get('/responses/:id/report/:token', responsesController.reportResponse);
router.post('/responses/:id/resend-verification', rateLimiter.general, responsesController.resendVerification);
// Admin and Campaign Owner Response Management Routes // Admin and Campaign Owner Response Management Routes
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses); router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);

View File

@ -0,0 +1,152 @@
const QRCode = require('qrcode');
const axios = require('axios');
const FormData = require('form-data');
/**
* QR Code Generation Service
* Generates QR codes for campaign and response wall URLs
*/
/**
* Generate QR code as PNG buffer
* @param {string} text - Text/URL to encode
* @param {Object} options - QR code options
* @returns {Promise<Buffer>} PNG buffer
*/
async function generateQRCode(text, options = {}) {
const defaultOptions = {
type: 'png',
width: 256,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
};
const qrOptions = { ...defaultOptions, ...options };
try {
const buffer = await QRCode.toBuffer(text, qrOptions);
return buffer;
} catch (error) {
console.error('Failed to generate QR code:', error);
throw new Error('Failed to generate QR code');
}
}
/**
* Upload QR code to NocoDB storage
* @param {Buffer} buffer - PNG buffer
* @param {string} filename - Filename for the upload
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload response
*/
async function uploadQRCodeToNocoDB(buffer, filename, config) {
const formData = new FormData();
formData.append('file', buffer, {
filename: filename,
contentType: 'image/png'
});
try {
// Use the base URL without /api/v1 for v2 endpoints
const baseUrl = config.apiUrl.replace('/api/v1', '');
const uploadUrl = `${baseUrl}/api/v2/storage/upload`;
console.log(`Uploading QR code to: ${uploadUrl}`);
const response = await axios({
url: uploadUrl,
method: 'post',
data: formData,
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
},
params: {
path: 'qrcodes'
}
});
console.log('QR code upload successful:', response.data);
return response.data;
} catch (error) {
console.error('Failed to upload QR code to NocoDB:', error.response?.data || error.message);
throw new Error('Failed to upload QR code');
}
}
/**
* Generate and upload QR code
* @param {string} url - URL to encode
* @param {string} label - Label for the QR code
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload result
*/
async function generateAndUploadQRCode(url, label, config) {
if (!url) {
return null;
}
try {
// Generate QR code
const buffer = await generateQRCode(url);
// Create filename
const timestamp = Date.now();
const safeLabel = label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const filename = `qr_${safeLabel}_${timestamp}.png`;
// Upload to NocoDB
const uploadResult = await uploadQRCodeToNocoDB(buffer, filename, config);
return uploadResult;
} catch (error) {
console.error('Failed to generate and upload QR code:', error);
throw error;
}
}
/**
* Delete QR code from NocoDB storage
* @param {string} fileUrl - File URL to delete
* @param {Object} config - NocoDB configuration
* @returns {Promise<boolean>} Success status
*/
async function deleteQRCodeFromNocoDB(fileUrl, config) {
if (!fileUrl) {
return true;
}
try {
// Extract file path from URL
const urlParts = fileUrl.split('/');
const filePath = urlParts.slice(-2).join('/');
await axios({
url: `${config.apiUrl}/api/v2/storage/upload`,
method: 'delete',
headers: {
'xc-token': config.apiToken
},
params: {
path: filePath
}
});
return true;
} catch (error) {
console.error('Failed to delete QR code from NocoDB:', error);
// Don't throw error for deletion failures
return false;
}
}
module.exports = {
generateQRCode,
uploadQRCodeToNocoDB,
generateAndUploadQRCode,
deleteQRCodeFromNocoDB
};

View File

@ -178,6 +178,12 @@ Business logic layer that handles HTTP requests and responses:
- `getCampaignBySlug()` - Public campaign access for landing pages and participation - `getCampaignBySlug()` - Public campaign access for landing pages and participation
- `participateInCampaign()` - Handles user participation, representative lookup, and email sending - `participateInCampaign()` - Handles user participation, representative lookup, and email sending
- `getCampaignAnalytics()` - Generates campaign performance metrics and participation statistics - `getCampaignAnalytics()` - Generates campaign performance metrics and participation statistics
- `generateQRCode()` - **NEW** Generates QR codes for campaign and response wall pages using qrcode service
- Supports both `type=campaign` and `type=response-wall` query parameters
- Returns 400x400px PNG images with high error correction (level H)
- Sets proper MIME types and cache headers (1 hour)
- Validates campaign existence before generation
- Uses APP_URL environment variable for absolute URL generation
- Cover photo feature: Supports image uploads (JPEG, PNG, GIF, WebP) up to 5MB, stores files in `/public/uploads/` with unique filenames, and saves filename references to NocoDB - Cover photo feature: Supports image uploads (JPEG, PNG, GIF, WebP) up to 5MB, stores files in `/public/uploads/` with unique filenames, and saves filename references to NocoDB
- Advanced features: attachment URL normalization, representative caching, email templating, and analytics tracking - Advanced features: attachment URL normalization, representative caching, email templating, and analytics tracking
- File upload middleware configuration using multer with diskStorage, file size limits, and image type validation - File upload middleware configuration using multer with diskStorage, file size limits, and image type validation
@ -222,6 +228,11 @@ API endpoint definitions and request validation:
- Campaign management endpoints: CRUD operations for campaigns, participation, analytics - Campaign management endpoints: CRUD operations for campaigns, participation, analytics
- GET `/api/public/campaigns` - **Public endpoint** (no auth required) returns all active campaigns with email counts if enabled - GET `/api/public/campaigns` - **Public endpoint** (no auth required) returns all active campaigns with email counts if enabled
- GET `/api/campaigns/:slug` - Public campaign lookup by URL slug for campaign landing pages - GET `/api/campaigns/:slug` - Public campaign lookup by URL slug for campaign landing pages
- GET `/api/campaigns/:slug/qrcode` - **NEW** Generate QR code for campaign or response wall
- Query parameter `type=campaign` (default) generates campaign page QR code
- Query parameter `type=response-wall` generates response wall page QR code
- Returns PNG image with proper MIME type and cache headers
- Used by frontend modal for display and download
- GET `/api/campaigns/:slug/representatives/:postalCode` - Get representatives for a campaign by postal code - GET `/api/campaigns/:slug/representatives/:postalCode` - Get representatives for a campaign by postal code
- POST `/api/campaigns/:slug/track-user` - Track user participation in campaigns - POST `/api/campaigns/:slug/track-user` - Track user participation in campaigns
- POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives - POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives
@ -283,6 +294,18 @@ External system integrations and data access layer:
- Email logging, delivery tracking, and bounce handling - Email logging, delivery tracking, and bounce handling
- Development mode with MailHog integration for local testing - Development mode with MailHog integration for local testing
- **`qrcode.js`** - QR code generation and management service
- `generateQRCode()` - Generates QR code PNG buffers from URLs or text
- Configurable size, margin, colors, and error correction levels
- Default: 256x256px, 1px margin, black/white, medium error correction
- Used by campaigns controller with 400x400px, high error correction for scanning reliability
- `uploadQRCodeToNocoDB()` - Uploads generated QR codes to NocoDB storage (currently unused in campaign flow)
- `generateAndUploadQRCode()` - Combined generation and upload workflow
- `deleteQRCodeFromNocoDB()` - Cleanup function for removing stored QR codes
- Uses `qrcode` npm package for image generation
- Returns raw PNG buffers for flexible usage (direct HTTP response, storage, etc.)
- Winston logging for debugging and error tracking
### Utilities (`app/utils/`) ### Utilities (`app/utils/`)
Helper functions and shared utilities: Helper functions and shared utilities:
@ -463,7 +486,13 @@ Professional HTML and text email templates with variable substitution:
- Representative lookup integration with postal code processing - Representative lookup integration with postal code processing
- Email composition interface with campaign context and template integration - Email composition interface with campaign context and template integration
- Progress tracking through campaign participation workflow - Progress tracking through campaign participation workflow
- Social sharing and engagement tracking functionality - Social sharing functionality with platform-specific handlers for Facebook, Twitter, LinkedIn, WhatsApp, Email, and Copy Link
- `openQRCodeModal()` - **NEW** Opens modal displaying QR code for campaign page
- Loads QR code image from `/api/campaigns/:slug/qrcode?type=campaign`
- Modal with close handlers (X button, outside click, ESC key)
- Download functionality to save QR code as PNG file
- No inline event handlers - all events bound via addEventListener
- Engagement tracking functionality
- **`campaigns-grid.js`** - Public campaigns grid display for homepage - **`campaigns-grid.js`** - Public campaigns grid display for homepage
- `CampaignsGrid` class for displaying active campaigns in a responsive card layout - `CampaignsGrid` class for displaying active campaigns in a responsive card layout
@ -535,6 +564,24 @@ Professional HTML and text email templates with variable substitution:
- Configuration status display and troubleshooting tools - Configuration status display and troubleshooting tools
- Real-time UI updates and comprehensive error handling - Real-time UI updates and comprehensive error handling
- **`response-wall.js`** - Response Wall page functionality and community interaction
- Manages the public-facing response wall for campaign-specific representative responses
- `loadResponses()` - Fetches and displays responses with pagination and filtering
- `loadResponseStats()` - Displays aggregate statistics (total, verified, upvotes)
- `openSubmitModal()` - Opens modal for users to submit new representative responses
- `handlePostalLookup()` - Integrates postal code lookup to auto-fill representative details
- `handleSubmitResponse()` - Processes response submission with screenshot upload support
- `renderResponse()` - Creates response cards with upvote buttons, verification badges, and metadata
- Social sharing functionality with platform-specific handlers for Facebook, Twitter, LinkedIn, WhatsApp, Email, and Copy Link
- `openQRCodeModal()` - **NEW** Opens modal displaying QR code for response wall page
- Loads QR code image from `/api/campaigns/:slug/qrcode?type=response-wall`
- Modal with close handlers (X button, outside click, ESC key)
- Download functionality to save QR code as PNG file with descriptive filename
- No inline event handlers - all events bound via addEventListener
- Filtering by government level and sorting by recent/upvotes/verified
- Upvote system with localStorage tracking to prevent duplicate votes
- Campaign integration via URL parameter `?campaign=slug`
- **`login.js`** - Login page functionality and user experience - **`login.js`** - Login page functionality and user experience
- Login form handling with client-side validation - Login form handling with client-side validation
- Integration with authentication API and session management - Integration with authentication API and session management