add verify button to the response wall and qr code generation
This commit is contained in:
parent
8372b8a4bd
commit
4b5e2249dd
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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">×</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>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
|
<p>© 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>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -515,8 +587,123 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
@ -434,6 +575,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">
|
||||||
<div class="response-rep-info">
|
<div class="response-rep-info">
|
||||||
@ -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>
|
||||||
`;
|
`;
|
||||||
@ -473,6 +627,14 @@ function createResponseCard(response) {
|
|||||||
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');
|
||||||
if (screenshotImg) {
|
if (screenshotImg) {
|
||||||
@ -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');
|
||||||
|
|||||||
@ -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">×</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>
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
152
influence/app/services/qrcode.js
Normal file
152
influence/app/services/qrcode.js
Normal 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
|
||||||
|
};
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user