Response wall build out
This commit is contained in:
parent
ccececaf25
commit
9da13d6d3d
@ -291,7 +291,7 @@ async function getResponseStats(req, res) {
|
||||
|
||||
// Get all approved responses for this campaign
|
||||
const responses = await nocodbService.getRepresentativeResponses({
|
||||
where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)`
|
||||
where: `(Campaign Slug,eq,${slug})~and(Status,eq,approved)`
|
||||
});
|
||||
|
||||
// Calculate stats
|
||||
@ -323,43 +323,91 @@ async function getResponseStats(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// Admin functions
|
||||
// Admin and campaign owner functions
|
||||
|
||||
/**
|
||||
* Get all responses (admin only - includes pending/rejected)
|
||||
* Get all responses (admin or campaign owners - includes pending/rejected)
|
||||
* Campaign owners only see responses to their own campaigns
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
async function getAdminResponses(req, res) {
|
||||
try {
|
||||
const { status = 'all', offset = 0, limit = 50 } = req.query;
|
||||
const { status = 'all', campaign_slug = '', offset = 0, limit = 50 } = req.query;
|
||||
|
||||
console.log(`Admin fetching responses with status filter: ${status}`);
|
||||
console.log(`User fetching responses with status filter: ${status}, campaign: ${campaign_slug}`);
|
||||
|
||||
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
|
||||
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
|
||||
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
|
||||
|
||||
// If not admin, we need to filter by campaign ownership
|
||||
let campaignsToFilter = [];
|
||||
if (!isAdmin) {
|
||||
// Get all campaigns owned by this user
|
||||
const allCampaigns = await nocodbService.getAllCampaigns();
|
||||
|
||||
campaignsToFilter = allCampaigns.filter(campaign => {
|
||||
const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null;
|
||||
const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null;
|
||||
|
||||
return (
|
||||
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
|
||||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
console.log(`User owns ${campaignsToFilter.length} campaigns out of ${allCampaigns.length} total`);
|
||||
}
|
||||
|
||||
let whereConditions = [];
|
||||
|
||||
// Filter by status
|
||||
if (status !== 'all') {
|
||||
whereConditions.push(`(Status,eq,${status})`);
|
||||
}
|
||||
|
||||
// Filter by campaign slug if provided
|
||||
if (campaign_slug) {
|
||||
const slugCondition = `(Campaign Slug,eq,${campaign_slug})`;
|
||||
if (whereConditions.length > 0) {
|
||||
whereConditions[0] += `~and${slugCondition}`;
|
||||
} else {
|
||||
whereConditions.push(slugCondition);
|
||||
}
|
||||
}
|
||||
|
||||
const responses = await nocodbService.getRepresentativeResponses({
|
||||
where: whereConditions.join(','),
|
||||
where: whereConditions.length > 0 ? whereConditions[0] : undefined,
|
||||
sort: '-CreatedAt',
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
console.log(`Admin found ${responses.length} total responses`);
|
||||
if (responses.length > 0) {
|
||||
console.log('Response statuses:', responses.map(r => `ID:${r.id} Status:${r.status}`).join(', '));
|
||||
console.log(`Found ${responses.length} total responses before filtering`);
|
||||
|
||||
// Filter responses by campaign ownership if not admin
|
||||
let filteredResponses = responses;
|
||||
if (!isAdmin && campaignsToFilter.length > 0) {
|
||||
const ownedCampaignSlugs = campaignsToFilter.map(c => c['Campaign Slug'] || c.slug);
|
||||
filteredResponses = responses.filter(r => ownedCampaignSlugs.includes(r.campaign_slug));
|
||||
console.log(`After ownership filter: ${filteredResponses.length} responses`);
|
||||
} else if (!isAdmin) {
|
||||
// User owns no campaigns, return empty
|
||||
filteredResponses = [];
|
||||
}
|
||||
|
||||
if (filteredResponses.length > 0) {
|
||||
console.log('Response statuses:', filteredResponses.map(r => `ID:${r.id} Status:${r.status}`).join(', '));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
responses,
|
||||
responses: filteredResponses,
|
||||
pagination: {
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit),
|
||||
hasMore: responses.length === parseInt(limit)
|
||||
hasMore: filteredResponses.length === parseInt(limit)
|
||||
}
|
||||
});
|
||||
|
||||
@ -370,7 +418,7 @@ async function getAdminResponses(req, res) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Update response status (admin only)
|
||||
* Update response status (admin or campaign owner)
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
@ -385,6 +433,38 @@ async function updateResponseStatus(req, res) {
|
||||
return res.status(400).json({ error: 'Invalid status' });
|
||||
}
|
||||
|
||||
// Get the response to check campaign ownership
|
||||
const response = await nocodbService.getRepresentativeResponseById(id);
|
||||
if (!response) {
|
||||
return res.status(404).json({ error: 'Response not found' });
|
||||
}
|
||||
|
||||
// Get campaign to check ownership
|
||||
const campaignId = response.campaign_id;
|
||||
const campaign = await nocodbService.getCampaignById(campaignId);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Check if user is admin or campaign owner
|
||||
const isAdmin = (req.user && req.user.isAdmin) || req.session?.isAdmin || false;
|
||||
const sessionUserId = req.user?.id ?? req.session?.userId ?? null;
|
||||
const sessionUserEmail = req.user?.email ?? req.session?.userEmail ?? null;
|
||||
|
||||
if (!isAdmin) {
|
||||
const createdById = campaign['Created By User ID'] ?? campaign.created_by_user_id ?? null;
|
||||
const createdByEmail = campaign['Created By User Email'] ?? campaign.created_by_user_email ?? null;
|
||||
|
||||
const ownsCampaign = (
|
||||
(createdById != null && sessionUserId != null && String(createdById) === String(sessionUserId)) ||
|
||||
(createdByEmail && sessionUserEmail && String(createdByEmail).toLowerCase() === String(sessionUserEmail).toLowerCase())
|
||||
);
|
||||
|
||||
if (!ownsCampaign) {
|
||||
return res.status(403).json({ error: 'You can only moderate responses to your own campaigns' });
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await nocodbService.updateRepresentativeResponse(id, { status });
|
||||
console.log('Updated response:', JSON.stringify(updated, null, 2));
|
||||
|
||||
|
||||
@ -687,6 +687,81 @@
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Response moderation styles */
|
||||
.response-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.response-card.pending {
|
||||
border-left: 4px solid #f39c12;
|
||||
}
|
||||
|
||||
.response-card.approved {
|
||||
border-left: 4px solid #27ae60;
|
||||
}
|
||||
|
||||
.response-card.rejected {
|
||||
border-left: 4px solid #e74c3c;
|
||||
}
|
||||
|
||||
.response-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-meta p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-content h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.response-content p {
|
||||
margin: 0;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.response-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.response-screenshot {
|
||||
max-width: 200px;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.response-screenshot:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-nav {
|
||||
flex-direction: column;
|
||||
@ -994,14 +1069,25 @@ Sincerely,
|
||||
|
||||
<!-- Response Moderation Tab -->
|
||||
<div id="responses-tab" class="tab-content">
|
||||
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
|
||||
<h2 style="margin: 0;">Response Moderation</h2>
|
||||
<select id="admin-response-status" class="form-control" style="max-width: 200px; margin-left: auto;">
|
||||
<option value="pending">Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
<h2>Response Moderation</h2>
|
||||
|
||||
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap;">
|
||||
<div class="campaign-selector" style="flex: 1; min-width: 300px; margin: 0;">
|
||||
<label for="admin-campaign-filter">Filter by Campaign:</label>
|
||||
<select id="admin-campaign-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||
<option value="">All Campaigns</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="campaign-selector" style="flex: 1; min-width: 200px; margin: 0;">
|
||||
<label for="admin-response-status">Filter by Status:</label>
|
||||
<select id="admin-response-status" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending" selected>Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="responses-loading" class="loading hidden">
|
||||
|
||||
@ -6,25 +6,33 @@
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 2.5rem;
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.9;
|
||||
font-size: 1rem;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.response-controls {
|
||||
|
||||
@ -510,6 +510,81 @@
|
||||
background: white;
|
||||
}
|
||||
|
||||
/* Response moderation styles */
|
||||
.response-card {
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.response-card.pending {
|
||||
border-left: 4px solid #f39c12;
|
||||
}
|
||||
|
||||
.response-card.approved {
|
||||
border-left: 4px solid #27ae60;
|
||||
}
|
||||
|
||||
.response-card.rejected {
|
||||
border-left: 4px solid #e74c3c;
|
||||
}
|
||||
|
||||
.response-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-meta p {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-content h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.response-content p {
|
||||
margin: 0;
|
||||
color: #444;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.response-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.response-screenshot {
|
||||
max-width: 200px;
|
||||
margin-top: 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.response-screenshot:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dashboard-nav {
|
||||
flex-direction: column;
|
||||
@ -557,6 +632,7 @@
|
||||
<button class="nav-btn active" data-tab="campaigns">My Campaigns</button>
|
||||
<button class="nav-btn" data-tab="create">Create Campaign</button>
|
||||
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
||||
<button class="nav-btn" data-tab="responses">Responses</button>
|
||||
<button class="nav-btn" data-tab="analytics">Analytics</button>
|
||||
<button class="nav-btn" data-tab="account">Account</button>
|
||||
</nav>
|
||||
@ -810,6 +886,7 @@ Sincerely,
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Tab -->
|
||||
<!-- Analytics Tab -->
|
||||
<div id="analytics-tab" class="tab-content">
|
||||
<h2>Your Campaign Analytics</h2>
|
||||
@ -841,6 +918,39 @@ Sincerely,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Responses Tab -->
|
||||
<div id="responses-tab" class="tab-content">
|
||||
<h2>Moderate Responses</h2>
|
||||
|
||||
<div style="display: flex; gap: 1rem; margin-bottom: 2rem; flex-wrap: wrap;">
|
||||
<div class="campaign-selector" style="flex: 1; min-width: 300px; margin: 0;">
|
||||
<label for="responses-campaign-filter">Filter by Campaign:</label>
|
||||
<select id="responses-campaign-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||
<option value="">All My Campaigns</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="campaign-selector" style="flex: 1; min-width: 200px; margin: 0;">
|
||||
<label for="responses-status-filter">Filter by Status:</label>
|
||||
<select id="responses-status-filter" style="width: 100%; padding: 0.75rem; border: 2px solid #e0e6ed; border-radius: 8px;">
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="pending" selected>Pending</option>
|
||||
<option value="approved">Approved</option>
|
||||
<option value="rejected">Rejected</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="responses-loading" class="loading hidden">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading responses...</p>
|
||||
</div>
|
||||
|
||||
<div id="responses-list">
|
||||
<!-- Responses will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Account Tab -->
|
||||
<div id="account-tab" class="tab-content">
|
||||
<h2>Account Settings</h2>
|
||||
|
||||
@ -117,6 +117,14 @@ class AdminPanel {
|
||||
this.loadAdminResponses();
|
||||
});
|
||||
}
|
||||
|
||||
// Response campaign filter
|
||||
const responseCampaignFilter = document.getElementById('admin-campaign-filter');
|
||||
if (responseCampaignFilter) {
|
||||
responseCampaignFilter.addEventListener('change', () => {
|
||||
this.loadAdminResponses();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setupFormInteractions() {
|
||||
@ -1065,14 +1073,23 @@ class AdminPanel {
|
||||
// Response Moderation Functions
|
||||
async loadAdminResponses() {
|
||||
const status = document.getElementById('admin-response-status').value;
|
||||
const campaignSlug = document.getElementById('admin-campaign-filter')?.value || '';
|
||||
const container = document.getElementById('admin-responses-container');
|
||||
const loading = document.getElementById('responses-loading');
|
||||
|
||||
// Populate campaign filter if not already done
|
||||
await this.populateAdminCampaignFilter();
|
||||
|
||||
loading.classList.remove('hidden');
|
||||
container.innerHTML = '';
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ status, limit: 100 });
|
||||
|
||||
if (campaignSlug) {
|
||||
params.append('campaign_slug', campaignSlug);
|
||||
}
|
||||
|
||||
const response = await window.apiClient.get(`/admin/responses?${params}`);
|
||||
|
||||
loading.classList.add('hidden');
|
||||
@ -1089,6 +1106,29 @@ class AdminPanel {
|
||||
}
|
||||
}
|
||||
|
||||
async populateAdminCampaignFilter() {
|
||||
const filterSelect = document.getElementById('admin-campaign-filter');
|
||||
if (!filterSelect || filterSelect.dataset.populated === 'true') return;
|
||||
|
||||
// Use already loaded campaigns or fetch them
|
||||
if (this.campaigns.length === 0) {
|
||||
await this.loadCampaigns();
|
||||
}
|
||||
|
||||
// Clear existing options except the first one
|
||||
filterSelect.innerHTML = '<option value="">All Campaigns</option>';
|
||||
|
||||
// Add campaign options
|
||||
this.campaigns.forEach(campaign => {
|
||||
const option = document.createElement('option');
|
||||
option.value = campaign.slug;
|
||||
option.textContent = campaign.title;
|
||||
filterSelect.appendChild(option);
|
||||
});
|
||||
|
||||
filterSelect.dataset.populated = 'true';
|
||||
}
|
||||
|
||||
renderAdminResponses(responses) {
|
||||
const container = document.getElementById('admin-responses-container');
|
||||
|
||||
|
||||
@ -121,6 +121,22 @@ class UserDashboard {
|
||||
});
|
||||
}
|
||||
|
||||
// Response filter changes
|
||||
const responseCampaignFilter = document.getElementById('responses-campaign-filter');
|
||||
const responseStatusFilter = document.getElementById('responses-status-filter');
|
||||
|
||||
if (responseCampaignFilter) {
|
||||
responseCampaignFilter.addEventListener('change', () => {
|
||||
this.loadResponses();
|
||||
});
|
||||
}
|
||||
|
||||
if (responseStatusFilter) {
|
||||
responseStatusFilter.addEventListener('change', () => {
|
||||
this.loadResponses();
|
||||
});
|
||||
}
|
||||
|
||||
// Campaign actions using event delegation
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('[data-action="view-campaign"]')) {
|
||||
@ -141,6 +157,16 @@ class UserDashboard {
|
||||
if (e.target.matches('[data-action="go-to-create"]')) {
|
||||
this.switchTab('create');
|
||||
}
|
||||
// Response moderation actions
|
||||
if (e.target.matches('[data-action="approve-response"]')) {
|
||||
this.moderateResponse(e.target.dataset.responseId, 'approved');
|
||||
}
|
||||
if (e.target.matches('[data-action="reject-response"]')) {
|
||||
this.moderateResponse(e.target.dataset.responseId, 'rejected');
|
||||
}
|
||||
if (e.target.matches('[data-action="pending-response"]')) {
|
||||
this.moderateResponse(e.target.dataset.responseId, 'pending');
|
||||
}
|
||||
});
|
||||
|
||||
// Setup campaign selector dropdowns
|
||||
@ -374,6 +400,8 @@ class UserDashboard {
|
||||
this.loadUserCampaigns();
|
||||
} else if (tabName === 'analytics') {
|
||||
this.loadAnalytics();
|
||||
} else if (tabName === 'responses') {
|
||||
this.loadResponses();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1015,6 +1043,183 @@ class UserDashboard {
|
||||
}
|
||||
}
|
||||
|
||||
// Response Moderation Methods
|
||||
async loadResponses() {
|
||||
const loadingDiv = document.getElementById('responses-loading');
|
||||
const listDiv = document.getElementById('responses-list');
|
||||
|
||||
if (loadingDiv) loadingDiv.classList.remove('hidden');
|
||||
|
||||
try {
|
||||
// Populate campaign filter dropdown if not already done
|
||||
await this.populateResponseCampaignFilter();
|
||||
|
||||
// Get filter values
|
||||
const campaignSlug = document.getElementById('responses-campaign-filter')?.value || '';
|
||||
const status = document.getElementById('responses-status-filter')?.value || 'all';
|
||||
|
||||
// Build query params
|
||||
const params = new URLSearchParams({
|
||||
status: status,
|
||||
limit: 100
|
||||
});
|
||||
|
||||
if (campaignSlug) {
|
||||
params.append('campaign_slug', campaignSlug);
|
||||
}
|
||||
|
||||
const response = await window.apiClient.get(`/admin/responses?${params.toString()}`);
|
||||
|
||||
if (response.success) {
|
||||
this.renderResponsesList(response.responses || []);
|
||||
} else {
|
||||
throw new Error(response.error || 'Failed to load responses');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load responses error:', error);
|
||||
if (listDiv) {
|
||||
listDiv.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>Error loading responses</h3>
|
||||
<p>${this.escapeHtml(error.message)}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
} finally {
|
||||
if (loadingDiv) loadingDiv.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async populateResponseCampaignFilter() {
|
||||
const filterSelect = document.getElementById('responses-campaign-filter');
|
||||
if (!filterSelect || filterSelect.dataset.populated === 'true') return;
|
||||
|
||||
// Use already loaded campaigns
|
||||
if (this.campaigns.length === 0) {
|
||||
await this.loadUserCampaigns();
|
||||
}
|
||||
|
||||
// Clear existing options except the first one
|
||||
filterSelect.innerHTML = '<option value="">All My Campaigns</option>';
|
||||
|
||||
// Add campaign options
|
||||
this.campaigns.forEach(campaign => {
|
||||
const option = document.createElement('option');
|
||||
option.value = campaign.slug;
|
||||
option.textContent = campaign.title;
|
||||
filterSelect.appendChild(option);
|
||||
});
|
||||
|
||||
filterSelect.dataset.populated = 'true';
|
||||
}
|
||||
|
||||
renderResponsesList(responses) {
|
||||
const listDiv = document.getElementById('responses-list');
|
||||
if (!listDiv) return;
|
||||
|
||||
if (responses.length === 0) {
|
||||
listDiv.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No responses found</h3>
|
||||
<p>There are no responses matching your filters.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
listDiv.innerHTML = responses.map(response => this.renderResponseCard(response)).join('');
|
||||
}
|
||||
|
||||
renderResponseCard(response) {
|
||||
const statusLabel = {
|
||||
pending: '⏳ Pending',
|
||||
approved: '✅ Approved',
|
||||
rejected: '❌ Rejected'
|
||||
}[response.status] || response.status;
|
||||
|
||||
const levelEmoji = {
|
||||
'Federal': '🍁',
|
||||
'Provincial': '🏛️',
|
||||
'Municipal': '🏙️',
|
||||
'School Board': '🎓'
|
||||
}[response.representative_level] || '📍';
|
||||
|
||||
// Show appropriate action buttons based on status
|
||||
let actionButtons = '';
|
||||
if (response.status === 'pending') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success" data-action="approve-response" data-response-id="${response.id}">✅ Approve</button>
|
||||
<button class="btn btn-danger" data-action="reject-response" data-response-id="${response.id}">❌ Reject</button>
|
||||
`;
|
||||
} else if (response.status === 'approved') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-secondary" data-action="pending-response" data-response-id="${response.id}">⏳ Move to Pending</button>
|
||||
<button class="btn btn-danger" data-action="reject-response" data-response-id="${response.id}">❌ Reject</button>
|
||||
`;
|
||||
} else if (response.status === 'rejected') {
|
||||
actionButtons = `
|
||||
<button class="btn btn-success" data-action="approve-response" data-response-id="${response.id}">✅ Approve</button>
|
||||
<button class="btn btn-secondary" data-action="pending-response" data-response-id="${response.id}">⏳ Move to Pending</button>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="response-card ${response.status}" data-response-id="${response.id}">
|
||||
<div class="response-header">
|
||||
<div>
|
||||
<span class="status-badge status-${response.status}">${statusLabel}</span>
|
||||
</div>
|
||||
<small style="color: #666;">Submitted ${this.formatDate(response.created_at)}</small>
|
||||
</div>
|
||||
|
||||
<div class="response-meta">
|
||||
<p><strong>Campaign:</strong> ${this.escapeHtml(response.campaign_slug)}</p>
|
||||
<p><strong>${levelEmoji} Representative:</strong> ${this.escapeHtml(response.representative_name)}</p>
|
||||
${response.representative_title ? `<p><strong>Title:</strong> ${this.escapeHtml(response.representative_title)}</p>` : ''}
|
||||
<p><strong>Response Type:</strong> ${this.escapeHtml(response.response_type)}</p>
|
||||
${response.submitted_by_name ? `<p><strong>Submitted By:</strong> ${this.escapeHtml(response.submitted_by_name)}</p>` : ''}
|
||||
${response.submitted_by_email ? `<p><strong>Email:</strong> ${this.escapeHtml(response.submitted_by_email)}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div class="response-content">
|
||||
<h4>Response:</h4>
|
||||
<p>${this.escapeHtml(response.response_text)}</p>
|
||||
${response.user_comment ? `
|
||||
<h4 style="margin-top: 1rem;">Submitter Comment:</h4>
|
||||
<p>${this.escapeHtml(response.user_comment)}</p>
|
||||
` : ''}
|
||||
${response.screenshot_url ? `
|
||||
<img src="${response.screenshot_url}" alt="Response screenshot" class="response-screenshot">
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="response-actions">
|
||||
${actionButtons}
|
||||
<span style="margin-left: auto; color: #666;">👍 ${response.upvote_count || 0} upvotes</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async moderateResponse(responseId, newStatus) {
|
||||
try {
|
||||
const response = await window.apiClient.patch(`/admin/responses/${responseId}/status`, {
|
||||
status: newStatus
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
this.showMessage(`Response ${newStatus} successfully!`, 'success');
|
||||
// Reload responses to update the display
|
||||
await this.loadResponses();
|
||||
} else {
|
||||
throw new Error(response.error || 'Failed to update response status');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Moderate response error:', error);
|
||||
this.showMessage('Failed to moderate response: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign Creation Methods
|
||||
async handleCreateCampaign(e) {
|
||||
e.preventDefault();
|
||||
|
||||
@ -214,9 +214,9 @@ router.post(
|
||||
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
|
||||
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
|
||||
|
||||
// Admin Response Management Routes
|
||||
router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses);
|
||||
router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general,
|
||||
// Admin and Campaign Owner Response Management Routes
|
||||
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
|
||||
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
|
||||
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
|
||||
handleValidationErrors,
|
||||
responsesController.updateResponseStatus
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user