New resposne wall coding started.
This commit is contained in:
parent
7cc6100e9b
commit
ccececaf25
191
influence/ADMIN_INLINE_HANDLER_FIX.md
Normal file
191
influence/ADMIN_INLINE_HANDLER_FIX.md
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
# Response Wall Admin Panel - Inline Handler Fix Summary
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
Content Security Policy (CSP) violation when clicking buttons in the Response Moderation tab of the admin panel.
|
||||||
|
|
||||||
|
## Error Messages
|
||||||
|
```
|
||||||
|
Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src-attr 'none'"
|
||||||
|
TypeError: window.apiClient.patch is not a function
|
||||||
|
```
|
||||||
|
|
||||||
|
## Root Causes
|
||||||
|
|
||||||
|
### 1. Inline Event Handlers (CSP Violation)
|
||||||
|
The admin panel's Response Moderation tab was using inline `onclick` handlers:
|
||||||
|
```javascript
|
||||||
|
<button onclick="adminPanel.approveResponse(${response.id})">✓ Approve</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
This violates:
|
||||||
|
- Browser Content Security Policy (CSP)
|
||||||
|
- Project development guidelines in `instruct.md`: **"No inline event handlers. Always use addEventListener in JS files."**
|
||||||
|
|
||||||
|
### 2. Missing API Client Methods
|
||||||
|
The `APIClient` class only had `get()` and `post()` methods, but admin operations needed `patch()`, `put()`, and `delete()`.
|
||||||
|
|
||||||
|
## Solutions Implemented
|
||||||
|
|
||||||
|
### Fix 1: Removed All Inline Handlers in admin.js
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```javascript
|
||||||
|
<button class="btn btn-success btn-sm" onclick="adminPanel.approveResponse(${response.id})">
|
||||||
|
✓ Approve
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```javascript
|
||||||
|
<button class="btn btn-success btn-sm"
|
||||||
|
data-action="approve-response"
|
||||||
|
data-response-id="${response.id}">
|
||||||
|
✓ Approve
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix 2: Added Event Delegation
|
||||||
|
|
||||||
|
Created new `setupResponseActionListeners()` method in `AdminPanel` class:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
setupResponseActionListeners() {
|
||||||
|
const container = document.getElementById('admin-responses-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Remove old listener if exists to avoid duplicates
|
||||||
|
const oldListener = container._responseActionListener;
|
||||||
|
if (oldListener) {
|
||||||
|
container.removeEventListener('click', oldListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new listener with event delegation
|
||||||
|
const listener = (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const action = target.dataset.action;
|
||||||
|
const responseId = target.dataset.responseId;
|
||||||
|
|
||||||
|
if (!action || !responseId) return;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'approve-response':
|
||||||
|
this.approveResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
case 'reject-response':
|
||||||
|
this.rejectResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
case 'verify-response':
|
||||||
|
const isVerified = target.dataset.verified === 'true';
|
||||||
|
this.toggleVerified(parseInt(responseId), isVerified);
|
||||||
|
break;
|
||||||
|
case 'delete-response':
|
||||||
|
this.deleteResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store listener reference and add it
|
||||||
|
container._responseActionListener = listener;
|
||||||
|
container.addEventListener('click', listener);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This method is called at the end of `renderAdminResponses()` to set up listeners after the HTML is rendered.
|
||||||
|
|
||||||
|
### Fix 3: Added Missing HTTP Methods to api-client.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async put(endpoint, data) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(endpoint, data) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(endpoint) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Buttons Fixed
|
||||||
|
|
||||||
|
All response moderation buttons now use proper event delegation:
|
||||||
|
|
||||||
|
1. **Approve** - `data-action="approve-response"`
|
||||||
|
2. **Reject** - `data-action="reject-response"`
|
||||||
|
3. **Mark as Verified** - `data-action="verify-response" data-verified="true"`
|
||||||
|
4. **Remove Verification** - `data-action="verify-response" data-verified="false"`
|
||||||
|
5. **Unpublish** - `data-action="reject-response"` (reuses reject action)
|
||||||
|
6. **Delete** - `data-action="delete-response"`
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **app/public/js/admin.js**
|
||||||
|
- Modified `renderAdminResponses()` - Replaced inline onclick with data attributes
|
||||||
|
- Added `setupResponseActionListeners()` - Event delegation implementation
|
||||||
|
- Total changes: ~85 lines modified/added
|
||||||
|
|
||||||
|
2. **app/public/js/api-client.js**
|
||||||
|
- Added `put()` method
|
||||||
|
- Added `patch()` method
|
||||||
|
- Added `delete()` method
|
||||||
|
- Total changes: ~18 lines added
|
||||||
|
|
||||||
|
## Benefits of This Approach
|
||||||
|
|
||||||
|
### 1. Security
|
||||||
|
- ✅ Complies with Content Security Policy (CSP)
|
||||||
|
- ✅ Prevents script injection attacks
|
||||||
|
- ✅ Follows modern web security best practices
|
||||||
|
|
||||||
|
### 2. Performance
|
||||||
|
- ✅ Single event listener instead of N listeners (one per button)
|
||||||
|
- ✅ Better memory usage
|
||||||
|
- ✅ Faster page rendering
|
||||||
|
|
||||||
|
### 3. Maintainability
|
||||||
|
- ✅ Follows project coding standards (instruct.md)
|
||||||
|
- ✅ Centralized event handling logic
|
||||||
|
- ✅ Easy to add new actions without HTML changes
|
||||||
|
|
||||||
|
### 4. Reliability
|
||||||
|
- ✅ Prevents duplicate listeners
|
||||||
|
- ✅ Clean listener removal/re-attachment
|
||||||
|
- ✅ Works with dynamically rendered content
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] Load admin panel → Response Moderation tab
|
||||||
|
- [x] Click "Approve" button → Should approve response without CSP error
|
||||||
|
- [x] Click "Reject" button → Should reject response
|
||||||
|
- [x] Click "Mark as Verified" → Should add verification badge
|
||||||
|
- [x] Click "Remove Verification" → Should remove badge
|
||||||
|
- [x] Click "Delete" → Should delete response after confirmation
|
||||||
|
- [x] Filter responses by status → Should reload list
|
||||||
|
- [x] No console errors related to inline handlers
|
||||||
|
- [x] No CSP violations
|
||||||
|
|
||||||
|
## Lessons Learned
|
||||||
|
|
||||||
|
1. **Always Follow Project Guidelines**: The instruct.md file explicitly prohibits inline event handlers - following it from the start would have prevented this issue
|
||||||
|
|
||||||
|
2. **Complete API Client**: When building a REST client, implement all HTTP methods (GET, POST, PUT, PATCH, DELETE) from the beginning
|
||||||
|
|
||||||
|
3. **Event Delegation for Dynamic Content**: When rendering content dynamically with buttons/links, always use event delegation on a parent container
|
||||||
|
|
||||||
|
4. **CSP is Your Friend**: Content Security Policy errors point to real security issues - fix them rather than disabling CSP
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- See `instruct.md` - Development Rules section: "No inline event handlers"
|
||||||
|
- See `RESPONSE_WALL_FIXES.md` - Full list of all Response Wall bug fixes
|
||||||
|
- See `RESPONSE_WALL_USAGE.md` - How to use the Response Wall feature
|
||||||
@ -10,6 +10,7 @@ A comprehensive web application that helps Alberta residents connect with their
|
|||||||
- **Direct Email**: Built-in email composer to contact representatives
|
- **Direct Email**: Built-in email composer to contact representatives
|
||||||
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
|
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
|
||||||
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
|
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
|
||||||
|
- **Response Wall**: Community-driven platform for sharing and voting on representative responses
|
||||||
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
|
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
|
||||||
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
|
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
|
||||||
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
||||||
@ -406,6 +407,33 @@ influence/
|
|||||||
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
|
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
|
||||||
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
|
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
|
||||||
|
|
||||||
|
### Response Wall Feature
|
||||||
|
The Response Wall creates transparency and accountability by allowing campaign participants to share responses they receive from elected representatives.
|
||||||
|
|
||||||
|
**Key Features:**
|
||||||
|
- **Public Response Sharing**: Constituents can post responses received via email, letter, phone, meetings, or social media
|
||||||
|
- **Community Voting**: Upvote system highlights helpful and representative responses
|
||||||
|
- **Verification System**: Admin-moderated verification badges for authentic responses
|
||||||
|
- **Screenshot Support**: Upload visual proof of responses (images up to 5MB)
|
||||||
|
- **Anonymous Posting**: Option to share responses without revealing identity
|
||||||
|
- **Filtering & Sorting**: Filter by government level, sort by recent/upvotes/verified
|
||||||
|
- **Engagement Statistics**: Track total responses, verified count, and community upvotes
|
||||||
|
- **Moderation Queue**: Admin panel for approving, rejecting, or verifying submitted responses
|
||||||
|
- **Campaign Integration**: Response walls linked to specific campaigns for contextualized feedback
|
||||||
|
|
||||||
|
**Access Response Wall:**
|
||||||
|
- Via campaign page: Add `?campaign=your-campaign-slug` parameter to `/response-wall.html`
|
||||||
|
- Admin moderation: Navigate to "Response Moderation" tab in admin panel
|
||||||
|
- Public viewing: All approved responses visible to encourage participation
|
||||||
|
|
||||||
|
**Moderation Workflow:**
|
||||||
|
1. Users submit responses with required details (representative name, level, type, response text)
|
||||||
|
2. Submissions enter "pending" status in moderation queue
|
||||||
|
3. Admins review and approve/reject from admin panel
|
||||||
|
4. Approved responses appear on public Response Wall
|
||||||
|
5. Admins can mark verified responses with special badge
|
||||||
|
6. Community upvotes highlight most impactful responses
|
||||||
|
|
||||||
### Email Integration
|
### Email Integration
|
||||||
- Modal-based email composer
|
- Modal-based email composer
|
||||||
- Pre-filled recipient information
|
- Pre-filled recipient information
|
||||||
|
|||||||
181
influence/RESPONSE_WALL_FIXES.md
Normal file
181
influence/RESPONSE_WALL_FIXES.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
# Response Wall Bug Fixes
|
||||||
|
|
||||||
|
## Issues Identified and Fixed
|
||||||
|
|
||||||
|
### 1. **TypeError: responses.filter is not a function**
|
||||||
|
**Error Location**: `app/controllers/responses.js:292` in `getResponseStats()`
|
||||||
|
|
||||||
|
**Root Cause**: The `getRepresentativeResponses()` method in `nocodb.js` was returning the raw NocoDB API response object `{list: [...], pageInfo: {...}}` instead of an array. The controller code expected an array and tried to call `.filter()` on an object.
|
||||||
|
|
||||||
|
**Fix Applied**: Modified `getRepresentativeResponses()` to extract the `list` array from the response and normalize each item:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
async getRepresentativeResponses(params = {}) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
const result = await this.getAll(this.tableIds.representativeResponses, params);
|
||||||
|
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
|
||||||
|
const list = result.list || [];
|
||||||
|
return list.map(item => this.normalizeResponse(item));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. **TypeError: responses.map is not a function**
|
||||||
|
**Error Location**: `app/controllers/responses.js:51` in `getCampaignResponses()`
|
||||||
|
|
||||||
|
**Root Cause**: Same as issue #1 - the method was returning an object instead of an array.
|
||||||
|
|
||||||
|
**Fix Applied**: Same fix as above ensures an array is always returned.
|
||||||
|
|
||||||
|
### 3. **Database Error: "A value is required for this field" (code 23502)**
|
||||||
|
**Error Location**: NocoDB database constraint violation when creating a response
|
||||||
|
|
||||||
|
**Root Cause**: The `campaign_id` field was being set to `null` or `undefined`. Investigation revealed that:
|
||||||
|
- The campaign object from NocoDB uses `Id` (capital I) as the primary key field
|
||||||
|
- The controller was trying to access `campaign.id` (lowercase) which returned `undefined`
|
||||||
|
- NocoDB's representative_responses table has `campaign_id` marked as required (`"rqd": true`)
|
||||||
|
|
||||||
|
**Fix Applied**:
|
||||||
|
1. Updated `submitResponse()` controller to check multiple possible field names:
|
||||||
|
```javascript
|
||||||
|
campaign_id: campaign.Id || campaign.id || campaign['Campaign ID'],
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Added validation in `createRepresentativeResponse()` to fail fast if campaign_id is missing:
|
||||||
|
```javascript
|
||||||
|
if (!responseData.campaign_id) {
|
||||||
|
throw new Error('Campaign ID is required for creating a response');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Added debug logging to track the campaign_id value:
|
||||||
|
```javascript
|
||||||
|
console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. **Array Handling in Response Upvotes**
|
||||||
|
**Potential Issue**: The `getResponseUpvotes()` method had the same array vs object issue
|
||||||
|
|
||||||
|
**Fix Applied**: Updated the method to return a normalized array:
|
||||||
|
```javascript
|
||||||
|
async getResponseUpvotes(params = {}) {
|
||||||
|
if (!this.tableIds.responseUpvotes) {
|
||||||
|
throw new Error('Response upvotes table not configured');
|
||||||
|
}
|
||||||
|
const result = await this.getAll(this.tableIds.responseUpvotes, params);
|
||||||
|
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
|
||||||
|
const list = result.list || [];
|
||||||
|
return list.map(item => this.normalizeUpvote(item));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Modified
|
||||||
|
|
||||||
|
1. **app/services/nocodb.js**
|
||||||
|
- Modified `getRepresentativeResponses()` - Extract and normalize list
|
||||||
|
- Modified `getResponseUpvotes()` - Extract and normalize list
|
||||||
|
- Modified `createRepresentativeResponse()` - Add campaign_id validation and logging
|
||||||
|
|
||||||
|
2. **app/controllers/responses.js**
|
||||||
|
- Modified `submitResponse()` - Handle multiple campaign ID field name variations (ID, Id, id)
|
||||||
|
- Added debug logging for campaign_id
|
||||||
|
|
||||||
|
3. **app/public/js/admin.js**
|
||||||
|
- Modified `renderAdminResponses()` - Removed all inline onclick handlers, replaced with data-action attributes
|
||||||
|
- Added `setupResponseActionListeners()` - Event delegation for response moderation buttons
|
||||||
|
- Follows instruct.md guidelines: "No inline event handlers. Always use addEventListener in JS files."
|
||||||
|
|
||||||
|
4. **app/public/js/api-client.js**
|
||||||
|
- Added `put()` method for HTTP PUT requests
|
||||||
|
- Added `patch()` method for HTTP PATCH requests
|
||||||
|
- Added `delete()` method for HTTP DELETE requests
|
||||||
|
- These methods were missing and causing errors in admin panel operations
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
After applying these fixes, test the following:
|
||||||
|
|
||||||
|
1. **Load Response Wall** - Visit `http://localhost:3333/response-wall.html?campaign=test-page`
|
||||||
|
- Stats should load without errors
|
||||||
|
- Response list should load without errors
|
||||||
|
|
||||||
|
2. **Submit Response** - Fill out and submit the response form
|
||||||
|
- Should successfully create a response in pending status
|
||||||
|
- Should return a success message
|
||||||
|
- Check logs for "Submitting response with campaign_id: [number]"
|
||||||
|
|
||||||
|
3. **Upvote Response** - Click the upvote button on an approved response
|
||||||
|
- Should increment the upvote count
|
||||||
|
- Should prevent duplicate upvotes
|
||||||
|
|
||||||
|
4. **Admin Moderation** - Visit `http://localhost:3333/admin.html` → Response Moderation tab
|
||||||
|
- Should see pending responses
|
||||||
|
- Should be able to approve/reject responses
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
The application container has been restarted with:
|
||||||
|
```bash
|
||||||
|
docker compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
All fixes are now live and ready for testing.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
The main issue was a misunderstanding of NocoDB's API response structure:
|
||||||
|
- **Expected**: Array of records directly
|
||||||
|
- **Actual**: Object with `{list: [records], pageInfo: {...}}`
|
||||||
|
|
||||||
|
This is a common pattern in REST APIs for pagination support. The fix ensures all service methods return properly normalized arrays for consistent usage throughout the application.
|
||||||
|
|
||||||
|
The secondary issue was field naming inconsistency:
|
||||||
|
- **NocoDB Primary Key**: Uses `ID` (all caps) not `Id` or `id`
|
||||||
|
- **Application Code**: Expected `id` (lowercase)
|
||||||
|
|
||||||
|
The fix handles all three variations to ensure compatibility: `campaign.ID || campaign.Id || campaign.id`
|
||||||
|
|
||||||
|
### 5. **CSP Violation: Inline Event Handlers in Admin Panel**
|
||||||
|
**Error**: "Refused to execute inline event handler because it violates the following Content Security Policy directive: 'script-src-attr 'none''"
|
||||||
|
|
||||||
|
**Root Cause**: The `renderAdminResponses()` method in admin.js was using inline `onclick` handlers like:
|
||||||
|
```javascript
|
||||||
|
<button onclick="adminPanel.approveResponse(${response.id})">
|
||||||
|
```
|
||||||
|
|
||||||
|
This violates the development guidelines in instruct.md which explicitly state: **"No inline event handlers. Always use addEventListener in JS files."**
|
||||||
|
|
||||||
|
**Fix Applied**:
|
||||||
|
- Replaced all inline onclick handlers with data-action attributes
|
||||||
|
- Created `setupResponseActionListeners()` method using event delegation
|
||||||
|
- All buttons now use pattern: `data-action="approve-response" data-response-id="${response.id}"`
|
||||||
|
- Event delegation listens on container and routes actions based on data attributes
|
||||||
|
|
||||||
|
### 6. **Missing HTTP Methods in API Client**
|
||||||
|
**Error**: "TypeError: window.apiClient.patch is not a function"
|
||||||
|
|
||||||
|
**Root Cause**: The APIClient class in api-client.js only had `get()` and `post()` methods. Admin panel operations needed `patch()`, `put()`, and `delete()` methods for updating and deleting responses.
|
||||||
|
|
||||||
|
**Fix Applied**: Added three new methods to APIClient:
|
||||||
|
```javascript
|
||||||
|
async put(endpoint, data) { ... }
|
||||||
|
async patch(endpoint, data) { ... }
|
||||||
|
async delete(endpoint) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prevention
|
||||||
|
|
||||||
|
To prevent similar issues in the future:
|
||||||
|
|
||||||
|
1. **Type Safety**: Consider adding TypeScript or JSDoc type annotations
|
||||||
|
2. **Consistent Normalization**: Always normalize data at the service layer
|
||||||
|
3. **Field Name Standards**: Document NocoDB field naming conventions
|
||||||
|
4. **Validation**: Add validation for required fields early in the request flow
|
||||||
|
5. **Logging**: Continue adding debug logging for data transformations
|
||||||
|
|
||||||
|
## Related Documentation
|
||||||
|
|
||||||
|
- See `RESPONSE_WALL_USAGE.md` for usage instructions
|
||||||
|
- See `RESPONSE_WALL_IMPLEMENTATION.md` for feature implementation details
|
||||||
|
- See `scripts/build-nocodb.sh` for database schema definitions
|
||||||
3624
influence/RESPONSE_WALL_IMPLEMENTATION.md
Normal file
3624
influence/RESPONSE_WALL_IMPLEMENTATION.md
Normal file
File diff suppressed because it is too large
Load Diff
204
influence/RESPONSE_WALL_USAGE.md
Normal file
204
influence/RESPONSE_WALL_USAGE.md
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# Response Wall - Usage Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
The Response Wall is now fully implemented! It allows campaign participants to share and vote on responses they receive from elected representatives.
|
||||||
|
|
||||||
|
## How to Access the Response Wall
|
||||||
|
|
||||||
|
The Response Wall requires a campaign slug to function. You must access it with a URL parameter:
|
||||||
|
|
||||||
|
### URL Format
|
||||||
|
```
|
||||||
|
http://localhost:3333/response-wall.html?campaign=YOUR-CAMPAIGN-SLUG
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example URLs
|
||||||
|
```
|
||||||
|
http://localhost:3333/response-wall.html?campaign=climate-action
|
||||||
|
http://localhost:3333/response-wall.html?campaign=healthcare-reform
|
||||||
|
http://localhost:3333/response-wall.html?campaign=education-funding
|
||||||
|
```
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Create a Campaign First
|
||||||
|
Before using the Response Wall, you need to have an active campaign:
|
||||||
|
|
||||||
|
1. Go to http://localhost:3333/admin.html
|
||||||
|
2. Create a new campaign with a slug (e.g., "climate-action")
|
||||||
|
3. Note the campaign slug you created
|
||||||
|
|
||||||
|
### 2. Access the Response Wall
|
||||||
|
Use the campaign slug in the URL:
|
||||||
|
```
|
||||||
|
http://localhost:3333/response-wall.html?campaign=YOUR-SLUG-HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### For Public Users
|
||||||
|
- **View Responses**: See all approved responses from representatives
|
||||||
|
- **Filter & Sort**:
|
||||||
|
- Filter by government level (Federal, Provincial, Municipal, School Board)
|
||||||
|
- Sort by Most Recent, Most Upvoted, or Verified First
|
||||||
|
- **Submit Responses**: Share responses you've received from representatives
|
||||||
|
- Required: Representative name, level, response type, response text
|
||||||
|
- Optional: Representative title, your comment, screenshot, your name/email
|
||||||
|
- Can post anonymously
|
||||||
|
- **Upvote Responses**: Show appreciation for helpful responses
|
||||||
|
- **View Statistics**: See total responses, verified count, and total upvotes
|
||||||
|
|
||||||
|
### For Administrators
|
||||||
|
Access via the admin panel at http://localhost:3333/admin.html:
|
||||||
|
|
||||||
|
1. Navigate to the "Response Moderation" tab
|
||||||
|
2. Filter by status: Pending, Approved, Rejected, or All
|
||||||
|
3. Moderate submissions:
|
||||||
|
- **Approve**: Make response visible to public
|
||||||
|
- **Reject**: Hide inappropriate responses
|
||||||
|
- **Verify**: Add verification badge to authentic responses
|
||||||
|
- **Edit**: Modify response content if needed
|
||||||
|
- **Delete**: Remove responses permanently
|
||||||
|
|
||||||
|
## Moderation Workflow
|
||||||
|
|
||||||
|
1. User submits a response (status: "pending")
|
||||||
|
2. Admin reviews in admin panel → "Response Moderation" tab
|
||||||
|
3. Admin approves → Response appears on public Response Wall
|
||||||
|
4. Admin can mark as "verified" for authenticity badge
|
||||||
|
5. Public can upvote helpful responses
|
||||||
|
|
||||||
|
## Integration with Campaigns
|
||||||
|
|
||||||
|
### Link from Campaign Pages
|
||||||
|
You can add links to the Response Wall from your campaign pages:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<a href="/response-wall.html?campaign=YOUR-SLUG">
|
||||||
|
View Community Responses
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Embed in Campaign Flow
|
||||||
|
Consider adding a "Share Your Response" call-to-action after users send emails to representatives.
|
||||||
|
|
||||||
|
## Testing the Feature
|
||||||
|
|
||||||
|
### 1. Create Test Campaign
|
||||||
|
```bash
|
||||||
|
# Via admin panel or API
|
||||||
|
POST /api/admin/campaigns
|
||||||
|
{
|
||||||
|
"title": "Test Campaign",
|
||||||
|
"slug": "test-campaign",
|
||||||
|
"email_subject": "Test",
|
||||||
|
"email_body": "Test body"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Submit Test Response
|
||||||
|
Navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:3333/response-wall.html?campaign=test-campaign
|
||||||
|
```
|
||||||
|
|
||||||
|
Click "Share a Response" and fill out the form.
|
||||||
|
|
||||||
|
### 3. Moderate Response
|
||||||
|
1. Go to admin panel: http://localhost:3333/admin.html
|
||||||
|
2. Click "Response Moderation" tab
|
||||||
|
3. Change filter to "Pending"
|
||||||
|
4. Approve the test response
|
||||||
|
|
||||||
|
### 4. View Public Response
|
||||||
|
Refresh the Response Wall page to see your approved response.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Public Endpoints
|
||||||
|
- `GET /api/campaigns/:slug/responses` - Get approved responses
|
||||||
|
- `GET /api/campaigns/:slug/response-stats` - Get statistics
|
||||||
|
- `POST /api/campaigns/:slug/responses` - Submit new response
|
||||||
|
- `POST /api/responses/:id/upvote` - Upvote a response
|
||||||
|
- `DELETE /api/responses/:id/upvote` - Remove upvote
|
||||||
|
|
||||||
|
### Admin Endpoints (require authentication)
|
||||||
|
- `GET /api/admin/responses` - Get all responses (any status)
|
||||||
|
- `PATCH /api/admin/responses/:id/status` - Update status
|
||||||
|
- `PATCH /api/admin/responses/:id` - Update response details
|
||||||
|
- `DELETE /api/admin/responses/:id` - Delete response
|
||||||
|
|
||||||
|
## Database Tables
|
||||||
|
|
||||||
|
The feature uses two new NocoDB tables:
|
||||||
|
|
||||||
|
### `influence_representative_responses`
|
||||||
|
Stores submitted responses with fields:
|
||||||
|
- Representative details (name, title, level)
|
||||||
|
- Response content (type, text, screenshot)
|
||||||
|
- Submitter info (name, email, anonymous flag)
|
||||||
|
- Moderation (status, verified flag)
|
||||||
|
- Engagement (upvote count)
|
||||||
|
|
||||||
|
### `influence_response_upvotes`
|
||||||
|
Tracks upvotes with fields:
|
||||||
|
- Response ID
|
||||||
|
- User ID / Email (if authenticated)
|
||||||
|
- IP address (for anonymous upvotes)
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### "No campaign specified" Error
|
||||||
|
**Problem**: Accessing `/response-wall.html` without campaign parameter
|
||||||
|
|
||||||
|
**Solution**: Add `?campaign=YOUR-SLUG` to the URL
|
||||||
|
|
||||||
|
### No Responses Showing
|
||||||
|
**Problem**: Responses exist but not visible
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. Responses are in "pending" status (not yet approved by admin)
|
||||||
|
2. Wrong campaign slug in URL
|
||||||
|
3. Responses filtered out by current filter settings
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Check admin panel → Response Moderation tab
|
||||||
|
- Verify campaign slug matches
|
||||||
|
- Clear filters (set to "All Levels")
|
||||||
|
|
||||||
|
### Button Not Working
|
||||||
|
**Problem**: "Share a Response" button doesn't open modal
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Check browser console for JavaScript errors
|
||||||
|
- Verify you're accessing with a valid campaign slug
|
||||||
|
- Hard refresh the page (Ctrl+F5)
|
||||||
|
|
||||||
|
### Images Not Uploading
|
||||||
|
**Problem**: Screenshot upload fails
|
||||||
|
|
||||||
|
**Possible Causes**:
|
||||||
|
1. File too large (max 5MB)
|
||||||
|
2. Wrong file type (only JPEG/PNG/GIF/WebP allowed)
|
||||||
|
3. Upload directory permissions
|
||||||
|
|
||||||
|
**Solution**:
|
||||||
|
- Resize image to under 5MB
|
||||||
|
- Convert to supported format
|
||||||
|
- Check `/app/public/uploads/responses/` directory exists with write permissions
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Run Database Setup**: `./scripts/build-nocodb.sh` (if not already done)
|
||||||
|
2. **Restart Application**: `docker compose down && docker compose up --build`
|
||||||
|
3. **Create Test Campaign**: Via admin panel
|
||||||
|
4. **Test Response Submission**: Submit and moderate a test response
|
||||||
|
5. **Integrate with Campaigns**: Add Response Wall links to your campaign pages
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- All submissions require admin moderation (status: "pending" → "approved")
|
||||||
|
- Anonymous upvotes are tracked by IP address to prevent duplicates
|
||||||
|
- Authenticated upvotes are tracked by user ID
|
||||||
|
- Screenshots are stored in `/app/public/uploads/responses/`
|
||||||
|
- The feature respects NocoDB best practices (column titles, system fields, etc.)
|
||||||
467
influence/app/controllers/responses.js
Normal file
467
influence/app/controllers/responses.js
Normal file
@ -0,0 +1,467 @@
|
|||||||
|
const nocodbService = require('../services/nocodb');
|
||||||
|
const { validateResponse } = require('../utils/validators');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all responses for a campaign
|
||||||
|
* Public endpoint - no authentication required
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function getCampaignResponses(req, res) {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const { sort = 'recent', level = '', offset = 0, limit = 20, status = 'approved' } = req.query;
|
||||||
|
|
||||||
|
// Get campaign by slug first
|
||||||
|
const campaign = await nocodbService.getCampaignBySlug(slug);
|
||||||
|
if (!campaign) {
|
||||||
|
return res.status(404).json({ error: 'Campaign not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter conditions
|
||||||
|
// NocoDB v2 API requires ~and operator between multiple conditions
|
||||||
|
let whereConditions = [
|
||||||
|
`(Campaign Slug,eq,${slug})~and(Status,eq,${status})`
|
||||||
|
];
|
||||||
|
|
||||||
|
if (level) {
|
||||||
|
whereConditions[0] += `~and(Representative Level,eq,${level})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build sort order
|
||||||
|
let sortOrder = '-CreatedAt'; // Default to most recent
|
||||||
|
if (sort === 'upvotes') {
|
||||||
|
sortOrder = '-Upvote Count';
|
||||||
|
} else if (sort === 'verified') {
|
||||||
|
sortOrder = '-Is Verified,-CreatedAt';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch responses
|
||||||
|
const responses = await nocodbService.getRepresentativeResponses({
|
||||||
|
where: whereConditions[0], // Use the first (and only) element
|
||||||
|
sort: sortOrder,
|
||||||
|
offset: parseInt(offset),
|
||||||
|
limit: parseInt(limit)
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Found ${responses.length} responses for campaign ${slug} with status ${status}`);
|
||||||
|
if (responses.length > 0) {
|
||||||
|
console.log('First response sample:', JSON.stringify(responses[0], null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each response, check if current user has upvoted
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const userEmail = req.user?.email;
|
||||||
|
|
||||||
|
const responsesWithUpvoteStatus = await Promise.all(responses.map(async (response) => {
|
||||||
|
let hasUpvoted = false;
|
||||||
|
|
||||||
|
if (userId || userEmail) {
|
||||||
|
const upvotes = await nocodbService.getResponseUpvotes({
|
||||||
|
where: `(Response ID,eq,${response.id})`
|
||||||
|
});
|
||||||
|
|
||||||
|
hasUpvoted = upvotes.some(upvote =>
|
||||||
|
(userId && upvote.user_id === userId) ||
|
||||||
|
(userEmail && upvote.user_email === userEmail)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
hasUpvoted
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
responses: responsesWithUpvoteStatus,
|
||||||
|
pagination: {
|
||||||
|
offset: parseInt(offset),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
hasMore: responses.length === parseInt(limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting campaign responses:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get campaign responses' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a new response
|
||||||
|
* Optional authentication - allows anonymous submissions
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function submitResponse(req, res) {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
const responseData = req.body;
|
||||||
|
|
||||||
|
// Get campaign by slug first
|
||||||
|
const campaign = await nocodbService.getCampaignBySlug(slug);
|
||||||
|
if (!campaign) {
|
||||||
|
return res.status(404).json({ error: 'Campaign not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response data
|
||||||
|
const validation = validateResponse(responseData);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return res.status(400).json({ error: validation.error });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle file upload if present
|
||||||
|
let screenshotUrl = null;
|
||||||
|
if (req.file) {
|
||||||
|
screenshotUrl = `/uploads/responses/${req.file.filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare response data for NocoDB
|
||||||
|
const newResponse = {
|
||||||
|
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
|
||||||
|
campaign_slug: slug,
|
||||||
|
representative_name: responseData.representative_name,
|
||||||
|
representative_title: responseData.representative_title || null,
|
||||||
|
representative_level: responseData.representative_level,
|
||||||
|
response_type: responseData.response_type,
|
||||||
|
response_text: responseData.response_text,
|
||||||
|
user_comment: responseData.user_comment || null,
|
||||||
|
screenshot_url: screenshotUrl,
|
||||||
|
submitted_by_name: responseData.submitted_by_name || null,
|
||||||
|
submitted_by_email: responseData.submitted_by_email || null,
|
||||||
|
submitted_by_user_id: req.user?.id || null,
|
||||||
|
is_anonymous: responseData.is_anonymous || false,
|
||||||
|
status: 'pending', // All submissions start as pending
|
||||||
|
is_verified: false,
|
||||||
|
upvote_count: 0,
|
||||||
|
submitted_ip: req.ip || req.connection.remoteAddress
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign);
|
||||||
|
|
||||||
|
// Create response in database
|
||||||
|
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
|
||||||
|
|
||||||
|
res.status(201).json({
|
||||||
|
success: true,
|
||||||
|
message: 'Response submitted successfully. It will be visible after moderation.',
|
||||||
|
response: createdResponse
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting response:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to submit response' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upvote a response
|
||||||
|
* Optional authentication - allows anonymous upvotes with IP tracking
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function upvoteResponse(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
const response = await nocodbService.getRepresentativeResponseById(id);
|
||||||
|
if (!response) {
|
||||||
|
return res.status(404).json({ error: 'Response not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const userEmail = req.user?.email;
|
||||||
|
const userIp = req.ip || req.connection.remoteAddress;
|
||||||
|
|
||||||
|
// Check if already upvoted
|
||||||
|
const existingUpvotes = await nocodbService.getResponseUpvotes({
|
||||||
|
where: `(Response ID,eq,${id})`
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyUpvoted = existingUpvotes.some(upvote =>
|
||||||
|
(userId && upvote.user_id === userId) ||
|
||||||
|
(userEmail && upvote.user_email === userEmail) ||
|
||||||
|
(upvote.upvoted_ip === userIp)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (alreadyUpvoted) {
|
||||||
|
return res.status(400).json({ error: 'You have already upvoted this response' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upvote
|
||||||
|
await nocodbService.createResponseUpvote({
|
||||||
|
response_id: id,
|
||||||
|
user_id: userId || null,
|
||||||
|
user_email: userEmail || null,
|
||||||
|
upvoted_ip: userIp
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment upvote count
|
||||||
|
const newCount = (response.upvote_count || 0) + 1;
|
||||||
|
await nocodbService.updateRepresentativeResponse(id, {
|
||||||
|
upvote_count: newCount
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Response upvoted successfully',
|
||||||
|
upvoteCount: newCount
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error upvoting response:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to upvote response' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove upvote from a response
|
||||||
|
* Optional authentication
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function removeUpvote(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Get response
|
||||||
|
const response = await nocodbService.getRepresentativeResponseById(id);
|
||||||
|
if (!response) {
|
||||||
|
return res.status(404).json({ error: 'Response not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = req.user?.id;
|
||||||
|
const userEmail = req.user?.email;
|
||||||
|
const userIp = req.ip || req.connection.remoteAddress;
|
||||||
|
|
||||||
|
// Find upvote to remove
|
||||||
|
const upvotes = await nocodbService.getResponseUpvotes({
|
||||||
|
where: `(Response ID,eq,${id})`
|
||||||
|
});
|
||||||
|
|
||||||
|
const upvoteToRemove = upvotes.find(upvote =>
|
||||||
|
(userId && upvote.user_id === userId) ||
|
||||||
|
(userEmail && upvote.user_email === userEmail) ||
|
||||||
|
(upvote.upvoted_ip === userIp)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!upvoteToRemove) {
|
||||||
|
return res.status(400).json({ error: 'You have not upvoted this response' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete upvote
|
||||||
|
await nocodbService.deleteResponseUpvote(upvoteToRemove.id);
|
||||||
|
|
||||||
|
// Decrement upvote count
|
||||||
|
const newCount = Math.max((response.upvote_count || 0) - 1, 0);
|
||||||
|
await nocodbService.updateRepresentativeResponse(id, {
|
||||||
|
upvote_count: newCount
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Upvote removed successfully',
|
||||||
|
upvoteCount: newCount
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error removing upvote:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to remove upvote' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get response statistics for a campaign
|
||||||
|
* Public endpoint
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function getResponseStats(req, res) {
|
||||||
|
try {
|
||||||
|
const { slug } = req.params;
|
||||||
|
|
||||||
|
// Get campaign by slug first
|
||||||
|
const campaign = await nocodbService.getCampaignBySlug(slug);
|
||||||
|
if (!campaign) {
|
||||||
|
return res.status(404).json({ error: 'Campaign not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all approved responses for this campaign
|
||||||
|
const responses = await nocodbService.getRepresentativeResponses({
|
||||||
|
where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const totalResponses = responses.length;
|
||||||
|
const verifiedResponses = responses.filter(r => r.is_verified).length;
|
||||||
|
const totalUpvotes = responses.reduce((sum, r) => sum + (r.upvote_count || 0), 0);
|
||||||
|
|
||||||
|
// Count by level
|
||||||
|
const byLevel = {
|
||||||
|
Federal: responses.filter(r => r.representative_level === 'Federal').length,
|
||||||
|
Provincial: responses.filter(r => r.representative_level === 'Provincial').length,
|
||||||
|
Municipal: responses.filter(r => r.representative_level === 'Municipal').length,
|
||||||
|
'School Board': responses.filter(r => r.representative_level === 'School Board').length
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
stats: {
|
||||||
|
totalResponses,
|
||||||
|
verifiedResponses,
|
||||||
|
totalUpvotes,
|
||||||
|
byLevel
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting response stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get response statistics' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin functions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all responses (admin only - includes pending/rejected)
|
||||||
|
* @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;
|
||||||
|
|
||||||
|
console.log(`Admin fetching responses with status filter: ${status}`);
|
||||||
|
|
||||||
|
let whereConditions = [];
|
||||||
|
if (status !== 'all') {
|
||||||
|
whereConditions.push(`(Status,eq,${status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const responses = await nocodbService.getRepresentativeResponses({
|
||||||
|
where: whereConditions.join(','),
|
||||||
|
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(', '));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
responses,
|
||||||
|
pagination: {
|
||||||
|
offset: parseInt(offset),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
hasMore: responses.length === parseInt(limit)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting admin responses:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to get responses' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update response status (admin only)
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function updateResponseStatus(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { status } = req.body;
|
||||||
|
|
||||||
|
console.log(`Updating response ${id} to status: ${status}`);
|
||||||
|
|
||||||
|
if (!['pending', 'approved', 'rejected'].includes(status)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid status' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await nocodbService.updateRepresentativeResponse(id, { status });
|
||||||
|
console.log('Updated response:', JSON.stringify(updated, null, 2));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Response ${status} successfully`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating response status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update response status' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update response (admin only)
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function updateResponse(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const updates = req.body;
|
||||||
|
|
||||||
|
await nocodbService.updateRepresentativeResponse(id, updates);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Response updated successfully'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating response:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update response' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete response (admin only)
|
||||||
|
* @param {Object} req - Express request object
|
||||||
|
* @param {Object} res - Express response object
|
||||||
|
*/
|
||||||
|
async function deleteResponse(req, res) {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Delete all upvotes for this response first
|
||||||
|
const upvotes = await nocodbService.getResponseUpvotes({
|
||||||
|
where: `(Response ID,eq,${id})`
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const upvote of upvotes) {
|
||||||
|
await nocodbService.deleteResponseUpvote(upvote.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the response
|
||||||
|
await nocodbService.deleteRepresentativeResponse(id);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Response deleted successfully'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting response:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to delete response' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getCampaignResponses,
|
||||||
|
submitResponse,
|
||||||
|
upvoteResponse,
|
||||||
|
removeUpvote,
|
||||||
|
getResponseStats,
|
||||||
|
getAdminResponses,
|
||||||
|
updateResponseStatus,
|
||||||
|
updateResponse,
|
||||||
|
deleteResponse
|
||||||
|
};
|
||||||
@ -162,8 +162,35 @@ const requireNonTemp = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optional authentication - sets req.user if authenticated, but doesn't block if not
|
||||||
|
const optionalAuth = async (req, res, next) => {
|
||||||
|
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||||
|
(req.session && req.session.userId && req.session.userEmail);
|
||||||
|
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Check if temp user has expired
|
||||||
|
const expirationResponse = await checkTempUserExpiration(req, res);
|
||||||
|
if (expirationResponse) {
|
||||||
|
return; // Response already sent by checkTempUserExpiration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up req.user object for controllers that expect it
|
||||||
|
req.user = {
|
||||||
|
id: req.session.userId,
|
||||||
|
email: req.session.userEmail,
|
||||||
|
isAdmin: req.session.isAdmin || false,
|
||||||
|
userType: req.session.userType || 'user',
|
||||||
|
name: req.session.userName || req.session.user_name || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Continue regardless of authentication status
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
requireAuth,
|
requireAuth,
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
requireNonTemp
|
requireNonTemp,
|
||||||
|
optionalAuth
|
||||||
};
|
};
|
||||||
47
influence/app/middleware/upload.js
Normal file
47
influence/app/middleware/upload.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Ensure uploads directory exists
|
||||||
|
const uploadDir = path.join(__dirname, '../public/uploads/responses');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure storage
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
// Generate unique filename: timestamp-random-originalname
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
const basename = path.basename(file.originalname, ext);
|
||||||
|
cb(null, `response-${uniqueSuffix}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// File filter - only images
|
||||||
|
const fileFilter = (req, file, cb) => {
|
||||||
|
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||||
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
|
if (mimetype && extname) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Configure multer
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024 // 5MB max file size
|
||||||
|
},
|
||||||
|
fileFilter: fileFilter
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = upload;
|
||||||
@ -726,6 +726,7 @@
|
|||||||
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
|
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
|
||||||
<button class="nav-btn" data-tab="create">Create Campaign</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="edit">Edit Campaign</button>
|
||||||
|
<button class="nav-btn" data-tab="responses">Response Moderation</button>
|
||||||
<button class="nav-btn" data-tab="users">User Management</button>
|
<button class="nav-btn" data-tab="users">User Management</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@ -991,6 +992,28 @@ Sincerely,
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="responses-loading" class="loading hidden">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
<p>Loading responses...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="admin-responses-container">
|
||||||
|
<!-- Responses will be loaded here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- User Management Tab -->
|
<!-- User Management Tab -->
|
||||||
<div id="users-tab" class="tab-content">
|
<div id="users-tab" class="tab-content">
|
||||||
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
|
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
|
||||||
|
|||||||
359
influence/app/public/css/response-wall.css
Normal file
359
influence/app/public/css/response-wall.css
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
/* Response Wall Styles */
|
||||||
|
|
||||||
|
.stats-banner {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group select {
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-response-btn {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Response Card */
|
||||||
|
.response-card {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e1e8ed;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
transition: box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-card:hover {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-rep-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-rep-info h3 {
|
||||||
|
margin: 0 0 0.25rem 0;
|
||||||
|
color: #1a202c;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-rep-info .rep-meta {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-rep-info .rep-meta span {
|
||||||
|
margin-right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-verified {
|
||||||
|
background: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-level {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-type {
|
||||||
|
background: #95a5a6;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-content {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-text {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 1rem;
|
||||||
|
border-left: 4px solid #3498db;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-comment {
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #fff3cd;
|
||||||
|
border-left: 4px solid #ffc107;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-comment-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #856404;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-screenshot {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-screenshot img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-screenshot img:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid #e1e8ed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-meta {
|
||||||
|
color: #7f8c8d;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 2px solid #3498db;
|
||||||
|
background: white;
|
||||||
|
color: #3498db;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote-btn:hover {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote-btn.upvoted {
|
||||||
|
background: #3498db;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote-btn .upvote-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upvote-count {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Styles */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: auto;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
margin: 5% auto;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 600px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
color: #aaa;
|
||||||
|
float: right;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover,
|
||||||
|
.close:focus {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a202c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select,
|
||||||
|
.form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-container {
|
||||||
|
text-align: center;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-banner {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit-response-btn {
|
||||||
|
margin-left: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-header {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-badges {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.response-footer {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
margin: 10% 5%;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -257,6 +257,10 @@ header p {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
/* Prevent width changes when inline composer is added */
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rep-category h3 {
|
.rep-category h3 {
|
||||||
@ -358,6 +362,169 @@ header p {
|
|||||||
border-color: #1e7e34;
|
border-color: #1e7e34;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Inline Email Composer Styles */
|
||||||
|
.inline-email-composer {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #005a9c;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.4s ease, padding 0.4s ease;
|
||||||
|
/* Ensure it's not affected by any parent grid/flex container */
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
grid-column: 1 / -1; /* Span full width if inside a grid */
|
||||||
|
display: block !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer.active {
|
||||||
|
max-height: 2000px;
|
||||||
|
opacity: 1;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer.closing {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .composer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .composer-header h3 {
|
||||||
|
color: #005a9c;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .close-btn {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .composer-body {
|
||||||
|
animation: slideIn 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateY(-20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .email-preview-details {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .detail-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .detail-row:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .detail-row strong {
|
||||||
|
color: #495057;
|
||||||
|
min-width: 90px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .detail-row span {
|
||||||
|
color: #333;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .email-preview-content {
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper for sanitized email HTML to prevent it affecting parent page */
|
||||||
|
.inline-email-composer .email-preview-body {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .email-preview-content p {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .email-preview-content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all inline composer child elements respect full width */
|
||||||
|
.inline-email-composer .composer-header,
|
||||||
|
.inline-email-composer .composer-body,
|
||||||
|
.inline-email-composer .recipient-info,
|
||||||
|
.inline-email-composer .inline-email-form,
|
||||||
|
.inline-email-composer .form-group,
|
||||||
|
.inline-email-composer .form-actions,
|
||||||
|
.inline-email-composer .email-preview-details,
|
||||||
|
.inline-email-composer .email-preview-content {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-email-composer .form-group input,
|
||||||
|
.inline-email-composer .form-group textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal Styles */
|
/* Modal Styles */
|
||||||
.modal {
|
.modal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@ -109,6 +109,14 @@ class AdminPanel {
|
|||||||
this.handleUserTypeChange(e.target.value);
|
this.handleUserTypeChange(e.target.value);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response status filter
|
||||||
|
const responseStatusSelect = document.getElementById('admin-response-status');
|
||||||
|
if (responseStatusSelect) {
|
||||||
|
responseStatusSelect.addEventListener('change', () => {
|
||||||
|
this.loadAdminResponses();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setupFormInteractions() {
|
setupFormInteractions() {
|
||||||
@ -407,6 +415,8 @@ class AdminPanel {
|
|||||||
if (this.currentCampaign) {
|
if (this.currentCampaign) {
|
||||||
this.populateEditForm();
|
this.populateEditForm();
|
||||||
}
|
}
|
||||||
|
} else if (tabName === 'responses') {
|
||||||
|
this.loadAdminResponses();
|
||||||
} else if (tabName === 'users') {
|
} else if (tabName === 'users') {
|
||||||
this.loadUsers();
|
this.loadUsers();
|
||||||
}
|
}
|
||||||
@ -1051,6 +1061,231 @@ class AdminPanel {
|
|||||||
this.showMessage('Failed to send emails: ' + error.message, 'error');
|
this.showMessage('Failed to send emails: ' + error.message, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Response Moderation Functions
|
||||||
|
async loadAdminResponses() {
|
||||||
|
const status = document.getElementById('admin-response-status').value;
|
||||||
|
const container = document.getElementById('admin-responses-container');
|
||||||
|
const loading = document.getElementById('responses-loading');
|
||||||
|
|
||||||
|
loading.classList.remove('hidden');
|
||||||
|
container.innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ status, limit: 100 });
|
||||||
|
const response = await window.apiClient.get(`/admin/responses?${params}`);
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
|
||||||
|
if (response.success && response.responses.length > 0) {
|
||||||
|
this.renderAdminResponses(response.responses);
|
||||||
|
} else {
|
||||||
|
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">No responses found.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
console.error('Error loading responses:', error);
|
||||||
|
this.showMessage('Failed to load responses', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderAdminResponses(responses) {
|
||||||
|
const container = document.getElementById('admin-responses-container');
|
||||||
|
|
||||||
|
console.log('Rendering admin responses:', responses.length, 'responses');
|
||||||
|
if (responses.length > 0) {
|
||||||
|
console.log('First response sample:', responses[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = responses.map(response => {
|
||||||
|
const createdDate = new Date(response.created_at).toLocaleString();
|
||||||
|
const statusClass = {
|
||||||
|
'pending': 'warning',
|
||||||
|
'approved': 'success',
|
||||||
|
'rejected': 'danger'
|
||||||
|
}[response.status] || 'secondary';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="response-admin-card" style="background: white; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
|
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
|
||||||
|
<div>
|
||||||
|
<h4 style="margin: 0 0 0.5rem 0;">${this.escapeHtml(response.representative_name)}</h4>
|
||||||
|
<div style="color: #7f8c8d; font-size: 0.9rem;">
|
||||||
|
<span>${this.escapeHtml(response.representative_level)}</span> •
|
||||||
|
<span>${this.escapeHtml(response.response_type)}</span> •
|
||||||
|
<span>${createdDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="badge badge-${statusClass}" style="padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.85rem;">
|
||||||
|
${response.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
${response.is_verified ? '<span class="badge badge-success" style="padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.85rem; margin-left: 0.5rem;">✓ VERIFIED</span>' : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="background: #f8f9fa; padding: 1rem; border-left: 4px solid #3498db; border-radius: 4px; margin-bottom: 1rem;">
|
||||||
|
<strong>Response:</strong>
|
||||||
|
<p style="margin: 0.5rem 0 0 0; white-space: pre-wrap;">${this.escapeHtml(response.response_text)}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${response.user_comment ? `
|
||||||
|
<div style="background: #fff3cd; padding: 0.75rem; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 1rem;">
|
||||||
|
<strong>User Comment:</strong>
|
||||||
|
<p style="margin: 0.5rem 0 0 0;">${this.escapeHtml(response.user_comment)}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${response.screenshot_url ? `
|
||||||
|
<div style="margin-bottom: 1rem;">
|
||||||
|
<img src="${this.escapeHtml(response.screenshot_url)}" style="max-width: 300px; border-radius: 4px; border: 1px solid #ddd;" alt="Screenshot">
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div style="color: #7f8c8d; font-size: 0.9rem; margin-bottom: 1rem;">
|
||||||
|
<strong>Submitted by:</strong> ${response.is_anonymous ? 'Anonymous' : (this.escapeHtml(response.submitted_by_name) || this.escapeHtml(response.submitted_by_email) || 'Unknown')} •
|
||||||
|
<strong>Campaign:</strong> ${this.escapeHtml(response.campaign_slug)} •
|
||||||
|
<strong>Upvotes:</strong> ${response.upvote_count || 0}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||||
|
${response.status === 'pending' ? `
|
||||||
|
<button class="btn btn-success btn-sm" data-action="approve-response" data-response-id="${response.id}">✓ Approve</button>
|
||||||
|
<button class="btn btn-danger btn-sm" data-action="reject-response" data-response-id="${response.id}">✗ Reject</button>
|
||||||
|
` : ''}
|
||||||
|
${response.status === 'approved' && !response.is_verified ? `
|
||||||
|
<button class="btn btn-primary btn-sm" data-action="verify-response" data-response-id="${response.id}" data-verified="true">Mark as Verified</button>
|
||||||
|
` : ''}
|
||||||
|
${response.status === 'approved' && response.is_verified ? `
|
||||||
|
<button class="btn btn-secondary btn-sm" data-action="verify-response" data-response-id="${response.id}" data-verified="false">Remove Verification</button>
|
||||||
|
` : ''}
|
||||||
|
${response.status === 'rejected' ? `
|
||||||
|
<button class="btn btn-success btn-sm" data-action="approve-response" data-response-id="${response.id}">✓ Approve</button>
|
||||||
|
` : ''}
|
||||||
|
${response.status === 'approved' ? `
|
||||||
|
<button class="btn btn-warning btn-sm" data-action="reject-response" data-response-id="${response.id}">Unpublish</button>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn btn-danger btn-sm" data-action="delete-response" data-response-id="${response.id}">🗑 Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// Add event delegation for response actions
|
||||||
|
this.setupResponseActionListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
async approveResponse(id) {
|
||||||
|
await this.updateResponseStatus(id, 'approved');
|
||||||
|
}
|
||||||
|
|
||||||
|
async rejectResponse(id) {
|
||||||
|
await this.updateResponseStatus(id, 'rejected');
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateResponseStatus(id, status) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiClient.patch(`/admin/responses/${id}/status`, { status });
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage(`Response ${status} successfully!`, 'success');
|
||||||
|
this.loadAdminResponses();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || 'Failed to update response status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating response status:', error);
|
||||||
|
this.showMessage('Failed to update response status: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggleVerified(id, isVerified) {
|
||||||
|
try {
|
||||||
|
const response = await window.apiClient.patch(`/admin/responses/${id}`, {
|
||||||
|
is_verified: isVerified
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage(isVerified ? 'Response marked as verified!' : 'Verification removed!', 'success');
|
||||||
|
this.loadAdminResponses();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || 'Failed to update verification status');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating verification:', error);
|
||||||
|
this.showMessage('Failed to update verification: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteResponse(id) {
|
||||||
|
if (!confirm('Are you sure you want to delete this response? This action cannot be undone.')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await window.apiClient.delete(`/admin/responses/${id}`);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
this.showMessage('Response deleted successfully!', 'success');
|
||||||
|
this.loadAdminResponses();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.error || 'Failed to delete response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting response:', error);
|
||||||
|
this.showMessage('Failed to delete response: ' + error.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupResponseActionListeners() {
|
||||||
|
const container = document.getElementById('admin-responses-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Remove old listener if exists to avoid duplicates
|
||||||
|
const oldListener = container._responseActionListener;
|
||||||
|
if (oldListener) {
|
||||||
|
container.removeEventListener('click', oldListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new listener
|
||||||
|
const listener = (e) => {
|
||||||
|
const target = e.target;
|
||||||
|
const action = target.dataset.action;
|
||||||
|
const responseId = target.dataset.responseId;
|
||||||
|
|
||||||
|
console.log('Response action clicked:', { action, responseId, target });
|
||||||
|
|
||||||
|
if (!action || !responseId) {
|
||||||
|
console.log('Missing action or responseId, ignoring click');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'approve-response':
|
||||||
|
this.approveResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
case 'reject-response':
|
||||||
|
this.rejectResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
case 'verify-response':
|
||||||
|
const isVerified = target.dataset.verified === 'true';
|
||||||
|
this.toggleVerified(parseInt(responseId), isVerified);
|
||||||
|
break;
|
||||||
|
case 'delete-response':
|
||||||
|
this.deleteResponse(parseInt(responseId));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store listener reference and add it
|
||||||
|
container._responseActionListener = listener;
|
||||||
|
container.addEventListener('click', listener);
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize admin panel when DOM is loaded
|
// Initialize admin panel when DOM is loaded
|
||||||
|
|||||||
@ -45,6 +45,26 @@ class APIClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async put(endpoint, data) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch(endpoint, data) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(endpoint) {
|
||||||
|
return this.makeRequest(endpoint, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async postFormData(endpoint, formData) {
|
async postFormData(endpoint, formData) {
|
||||||
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
|
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@ -16,6 +16,8 @@ class EmailComposer {
|
|||||||
this.currentRecipient = null;
|
this.currentRecipient = null;
|
||||||
this.currentEmailData = null;
|
this.currentEmailData = null;
|
||||||
this.lastPreviewTime = 0; // Track last preview request time
|
this.lastPreviewTime = 0; // Track last preview request time
|
||||||
|
this.currentInlineComposer = null; // Track currently open inline composer
|
||||||
|
this.currentRepCard = null; // Track which rep card has the composer open
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +69,533 @@ class EmailComposer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
openModal(recipient) {
|
openModal(recipient, repCard = null) {
|
||||||
|
// Close any existing inline composers first
|
||||||
|
this.closeInlineComposer();
|
||||||
|
|
||||||
|
this.currentRecipient = recipient;
|
||||||
|
this.currentRepCard = repCard;
|
||||||
|
|
||||||
|
// If repCard is provided, create inline composer
|
||||||
|
if (repCard) {
|
||||||
|
this.createInlineComposer(repCard, recipient);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use modal (fallback)
|
||||||
|
this.openModalDialog(recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
createInlineComposer(repCard, recipient) {
|
||||||
|
// Explicitly hide old modals
|
||||||
|
if (this.modal) this.modal.style.display = 'none';
|
||||||
|
if (this.previewModal) this.previewModal.style.display = 'none';
|
||||||
|
|
||||||
|
// Create the inline composer HTML
|
||||||
|
const composerHTML = this.getComposerHTML(recipient);
|
||||||
|
|
||||||
|
// Create a container div
|
||||||
|
const composerDiv = document.createElement('div');
|
||||||
|
composerDiv.className = 'inline-email-composer';
|
||||||
|
composerDiv.innerHTML = composerHTML;
|
||||||
|
|
||||||
|
// Find the parent category container (not the grid)
|
||||||
|
// The structure is: rep-category > rep-cards (grid) > rep-card
|
||||||
|
const repCardsGrid = repCard.parentElement; // The grid container
|
||||||
|
const repCategory = repCardsGrid.parentElement; // The category container
|
||||||
|
|
||||||
|
// Insert after the grid, not inside it
|
||||||
|
repCategory.insertAdjacentElement('beforeend', composerDiv);
|
||||||
|
this.currentInlineComposer = composerDiv;
|
||||||
|
|
||||||
|
// Trigger animation after a small delay
|
||||||
|
setTimeout(() => {
|
||||||
|
composerDiv.classList.add('active');
|
||||||
|
}, 10);
|
||||||
|
|
||||||
|
// Attach event listeners to the inline form
|
||||||
|
this.attachInlineFormListeners(composerDiv);
|
||||||
|
|
||||||
|
// Focus on first input
|
||||||
|
setTimeout(() => {
|
||||||
|
const firstInput = composerDiv.querySelector('#inline-sender-name');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
}, 400);
|
||||||
|
|
||||||
|
// Scroll the composer into view
|
||||||
|
setTimeout(() => {
|
||||||
|
composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComposerHTML(recipient) {
|
||||||
|
const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : '';
|
||||||
|
const defaultSubject = `Message from your constituent from ${postalCode}`;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="composer-header">
|
||||||
|
<h3>Compose Email to ${recipient.name}</h3>
|
||||||
|
<button class="close-btn inline-close" type="button">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="composer-body">
|
||||||
|
<div class="recipient-info">
|
||||||
|
<strong>${recipient.name}</strong><br>
|
||||||
|
${recipient.office}<br>
|
||||||
|
${recipient.district}<br>
|
||||||
|
<em>${recipient.email}</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="inline-email-form">
|
||||||
|
<input type="hidden" id="inline-recipient-email" value="${recipient.email}">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-name">Your Name *</label>
|
||||||
|
<input type="text" id="inline-sender-name" name="sender-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-email">Your Email *</label>
|
||||||
|
<input type="email" id="inline-sender-email" name="sender-email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-postal-code">Your Postal Code *</label>
|
||||||
|
<input type="text"
|
||||||
|
id="inline-sender-postal-code"
|
||||||
|
name="sender-postal-code"
|
||||||
|
value="${postalCode}"
|
||||||
|
pattern="[A-Za-z]\\d[A-Za-z]\\s?\\d[A-Za-z]\\d"
|
||||||
|
placeholder="e.g., T5N 4B8"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-email-subject">Subject *</label>
|
||||||
|
<input type="text" id="inline-email-subject" name="email-subject" value="${defaultSubject}" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-email-message">Your Message *</label>
|
||||||
|
<textarea id="inline-email-message" name="email-message" rows="8" maxlength="5000" required></textarea>
|
||||||
|
<span class="char-counter inline-char-counter">5000 characters remaining</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary inline-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Preview & Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachInlineFormListeners(composerDiv) {
|
||||||
|
// Close button
|
||||||
|
const closeBtn = composerDiv.querySelector('.inline-close');
|
||||||
|
closeBtn.addEventListener('click', () => this.closeInlineComposer());
|
||||||
|
|
||||||
|
// Cancel button
|
||||||
|
const cancelBtn = composerDiv.querySelector('.inline-cancel');
|
||||||
|
cancelBtn.addEventListener('click', () => this.closeInlineComposer());
|
||||||
|
|
||||||
|
// Form submission
|
||||||
|
const form = composerDiv.querySelector('.inline-email-form');
|
||||||
|
form.addEventListener('submit', (e) => this.handleInlinePreview(e, composerDiv));
|
||||||
|
|
||||||
|
// Character counter
|
||||||
|
const messageTextarea = composerDiv.querySelector('#inline-email-message');
|
||||||
|
const charCounter = composerDiv.querySelector('.inline-char-counter');
|
||||||
|
messageTextarea.addEventListener('input', () => {
|
||||||
|
const maxLength = 5000;
|
||||||
|
const currentLength = messageTextarea.value.length;
|
||||||
|
const remaining = maxLength - currentLength;
|
||||||
|
charCounter.textContent = `${remaining} characters remaining`;
|
||||||
|
if (remaining < 100) {
|
||||||
|
charCounter.style.color = '#d9534f';
|
||||||
|
} else {
|
||||||
|
charCounter.style.color = '#666';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update subject with sender name
|
||||||
|
const senderNameField = composerDiv.querySelector('#inline-sender-name');
|
||||||
|
const subjectField = composerDiv.querySelector('#inline-email-subject');
|
||||||
|
const postalCodeField = composerDiv.querySelector('#inline-sender-postal-code');
|
||||||
|
|
||||||
|
senderNameField.addEventListener('input', () => {
|
||||||
|
const senderName = senderNameField.value.trim();
|
||||||
|
const postalCode = postalCodeField.value.trim();
|
||||||
|
if (senderName && postalCode) {
|
||||||
|
subjectField.value = `Message from ${senderName} - ${postalCode}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
closeInlineComposer() {
|
||||||
|
if (this.currentInlineComposer) {
|
||||||
|
// Add closing class for animation
|
||||||
|
this.currentInlineComposer.classList.remove('active');
|
||||||
|
this.currentInlineComposer.classList.add('closing');
|
||||||
|
|
||||||
|
// Remove from DOM after animation
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.currentInlineComposer && this.currentInlineComposer.parentNode) {
|
||||||
|
this.currentInlineComposer.parentNode.removeChild(this.currentInlineComposer);
|
||||||
|
}
|
||||||
|
this.currentInlineComposer = null;
|
||||||
|
this.currentRepCard = null;
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInlinePreview(e, composerDiv) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Prevent duplicate calls
|
||||||
|
const currentTime = Date.now();
|
||||||
|
if (currentTime - this.lastPreviewTime < 2000) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.lastPreviewTime = currentTime;
|
||||||
|
|
||||||
|
// Get form data from inline form
|
||||||
|
const formData = this.getInlineFormData(composerDiv);
|
||||||
|
const errors = this.validateInlineForm(formData);
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
window.messageDisplay.show(errors.join('<br>'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitButton = composerDiv.querySelector('button[type="submit"]');
|
||||||
|
const originalText = submitButton.textContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
submitButton.disabled = true;
|
||||||
|
submitButton.textContent = 'Loading preview...';
|
||||||
|
|
||||||
|
// Store form data for later use - match API expected field names
|
||||||
|
this.currentEmailData = {
|
||||||
|
recipientEmail: formData.recipientEmail,
|
||||||
|
senderName: formData.senderName,
|
||||||
|
senderEmail: formData.senderEmail,
|
||||||
|
postalCode: formData.postalCode, // API expects 'postalCode' not 'senderPostalCode'
|
||||||
|
subject: formData.subject,
|
||||||
|
message: formData.message
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get preview from backend
|
||||||
|
const response = await window.apiClient.previewEmail(this.currentEmailData);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// Replace inline composer with inline preview
|
||||||
|
this.showInlinePreview(response.preview, composerDiv);
|
||||||
|
} else {
|
||||||
|
window.messageDisplay.show(response.error || 'Failed to generate preview', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Preview error:', error);
|
||||||
|
window.messageDisplay.show('Failed to generate email preview. Please try again.', 'error');
|
||||||
|
} finally {
|
||||||
|
submitButton.disabled = false;
|
||||||
|
submitButton.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getInlineFormData(composerDiv) {
|
||||||
|
return {
|
||||||
|
recipientEmail: composerDiv.querySelector('#inline-recipient-email').value.trim(),
|
||||||
|
senderName: composerDiv.querySelector('#inline-sender-name').value.trim(),
|
||||||
|
senderEmail: composerDiv.querySelector('#inline-sender-email').value.trim(),
|
||||||
|
postalCode: composerDiv.querySelector('#inline-sender-postal-code').value.trim(),
|
||||||
|
subject: composerDiv.querySelector('#inline-email-subject').value.trim(),
|
||||||
|
message: composerDiv.querySelector('#inline-email-message').value.trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
validateInlineForm(formData) {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!formData.senderName) {
|
||||||
|
errors.push('Please enter your name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.senderEmail) {
|
||||||
|
errors.push('Please enter your email');
|
||||||
|
} else if (!this.validateEmail(formData.senderEmail)) {
|
||||||
|
errors.push('Please enter a valid email address');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.subject) {
|
||||||
|
errors.push('Please enter a subject');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!formData.message) {
|
||||||
|
errors.push('Please enter a message');
|
||||||
|
} else if (formData.message.length < 10) {
|
||||||
|
errors.push('Message must be at least 10 characters long');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate postal code format if provided (required by API)
|
||||||
|
if (!formData.postalCode || formData.postalCode.trim() === '') {
|
||||||
|
errors.push('Postal code is required');
|
||||||
|
} else {
|
||||||
|
// Check postal code format: A1A 1A1 or A1A1A1
|
||||||
|
const postalCodePattern = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
|
||||||
|
if (!postalCodePattern.test(formData.postalCode)) {
|
||||||
|
errors.push('Please enter a valid Canadian postal code (e.g., T5N 4B8)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious content
|
||||||
|
if (this.containsSuspiciousContent(formData.message) || this.containsSuspiciousContent(formData.subject)) {
|
||||||
|
errors.push('Message contains potentially malicious content');
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
showInlinePreview(preview, composerDiv) {
|
||||||
|
// Ensure old preview modal is hidden
|
||||||
|
if (this.previewModal) {
|
||||||
|
this.previewModal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the composer content with preview content
|
||||||
|
const previewHTML = this.getPreviewHTML(preview);
|
||||||
|
|
||||||
|
// Fade out composer body
|
||||||
|
const composerBody = composerDiv.querySelector('.composer-body');
|
||||||
|
composerBody.style.opacity = '0';
|
||||||
|
composerBody.style.transition = 'opacity 0.2s ease';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
composerBody.innerHTML = previewHTML;
|
||||||
|
composerBody.style.opacity = '1';
|
||||||
|
|
||||||
|
// Attach event listeners to preview buttons
|
||||||
|
this.attachPreviewListeners(composerDiv);
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
const header = composerDiv.querySelector('.composer-header h3');
|
||||||
|
header.textContent = 'Email Preview';
|
||||||
|
|
||||||
|
// Scroll to make sure preview is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewHTML(preview) {
|
||||||
|
const html = preview.html || '';
|
||||||
|
const testModeWarning = preview.testMode ?
|
||||||
|
`<div style="background: #fff3cd; color: #856404; padding: 10px; border-radius: 4px; margin-bottom: 15px;">
|
||||||
|
<strong>TEST MODE:</strong> Email will be redirected to ${this.escapeHtml(preview.redirectTo)}
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
|
// Sanitize the HTML to remove any <style> tags or <body> styles that would affect the page
|
||||||
|
const sanitizedHtml = this.sanitizeEmailHTML(html);
|
||||||
|
|
||||||
|
return `
|
||||||
|
${testModeWarning}
|
||||||
|
<div class="email-preview-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>From:</strong> <span>${this.escapeHtml(preview.from)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>To:</strong> <span>${this.escapeHtml(preview.to)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>Subject:</strong> <span>${this.escapeHtml(preview.subject)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<strong>Timestamp:</strong> <span>${new Date(preview.timestamp).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin: 20px 0;">
|
||||||
|
<strong style="display: block; margin-bottom: 10px; color: #005a9c;">Message Preview:</strong>
|
||||||
|
<div class="email-preview-content">
|
||||||
|
${sanitizedHtml}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary preview-edit">← Edit Email</button>
|
||||||
|
<button type="button" class="btn btn-primary preview-send">Send Email →</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitizeEmailHTML(html) {
|
||||||
|
// Remove DOCTYPE, html, head, body tags and their attributes
|
||||||
|
// These can interfere with the parent page's styling
|
||||||
|
let sanitized = html
|
||||||
|
.replace(/<!DOCTYPE[^>]*>/gi, '')
|
||||||
|
.replace(/<html[^>]*>/gi, '')
|
||||||
|
.replace(/<\/html>/gi, '')
|
||||||
|
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
|
||||||
|
.replace(/<body[^>]*>/gi, '<div>')
|
||||||
|
.replace(/<\/body>/gi, '</div>');
|
||||||
|
|
||||||
|
// Remove or modify style tags that could affect the parent page
|
||||||
|
// Wrap them in a scoped container instead of removing entirely
|
||||||
|
sanitized = sanitized.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (match, styleContent) => {
|
||||||
|
// Remove any 'body' selectors and replace with a safe class
|
||||||
|
const scopedStyles = styleContent
|
||||||
|
.replace(/\bbody\s*{/gi, '.email-preview-body {')
|
||||||
|
.replace(/\bhtml\s*{/gi, '.email-preview-body {');
|
||||||
|
return `<style>${scopedStyles}</style>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wrap the sanitized content in a container div
|
||||||
|
return `<div class="email-preview-body">${sanitized}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachPreviewListeners(composerDiv) {
|
||||||
|
// Edit button - go back to composer
|
||||||
|
const editBtn = composerDiv.querySelector('.preview-edit');
|
||||||
|
editBtn.addEventListener('click', () => {
|
||||||
|
this.returnToComposer(composerDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send button - send the email
|
||||||
|
const sendBtn = composerDiv.querySelector('.preview-send');
|
||||||
|
sendBtn.addEventListener('click', async () => {
|
||||||
|
await this.sendFromInlinePreview(sendBtn, composerDiv);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
returnToComposer(composerDiv) {
|
||||||
|
// Restore the composer form with the current data
|
||||||
|
const composerFormHTML = this.getComposerFormHTML();
|
||||||
|
|
||||||
|
const composerBody = composerDiv.querySelector('.composer-body');
|
||||||
|
composerBody.style.opacity = '0';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
composerBody.innerHTML = composerFormHTML;
|
||||||
|
|
||||||
|
// Re-populate form fields with saved data
|
||||||
|
if (this.currentEmailData) {
|
||||||
|
composerDiv.querySelector('#inline-sender-name').value = this.currentEmailData.senderName || '';
|
||||||
|
composerDiv.querySelector('#inline-sender-email').value = this.currentEmailData.senderEmail || '';
|
||||||
|
composerDiv.querySelector('#inline-sender-postal-code').value = this.currentEmailData.postalCode || '';
|
||||||
|
composerDiv.querySelector('#inline-email-subject').value = this.currentEmailData.subject || '';
|
||||||
|
composerDiv.querySelector('#inline-email-message').value = this.currentEmailData.message || '';
|
||||||
|
|
||||||
|
// Update character counter
|
||||||
|
const messageField = composerDiv.querySelector('#inline-email-message');
|
||||||
|
const charCounter = composerDiv.querySelector('.inline-char-counter');
|
||||||
|
const remaining = 5000 - messageField.value.length;
|
||||||
|
charCounter.textContent = `${remaining} characters remaining`;
|
||||||
|
}
|
||||||
|
|
||||||
|
composerBody.style.opacity = '1';
|
||||||
|
|
||||||
|
// Re-attach form listeners
|
||||||
|
this.attachInlineFormListeners(composerDiv);
|
||||||
|
|
||||||
|
// Update header
|
||||||
|
const header = composerDiv.querySelector('.composer-header h3');
|
||||||
|
header.textContent = `Compose Email to ${this.currentRecipient.name}`;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
getComposerFormHTML() {
|
||||||
|
const recipient = this.currentRecipient;
|
||||||
|
return `
|
||||||
|
<div class="recipient-info">
|
||||||
|
<strong>${recipient.name}</strong><br>
|
||||||
|
${recipient.office}<br>
|
||||||
|
${recipient.district}<br>
|
||||||
|
<em>${recipient.email}</em>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="inline-email-form">
|
||||||
|
<input type="hidden" id="inline-recipient-email" value="${recipient.email}">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-name">Your Name *</label>
|
||||||
|
<input type="text" id="inline-sender-name" name="sender-name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-email">Your Email *</label>
|
||||||
|
<input type="email" id="inline-sender-email" name="sender-email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-sender-postal-code">Your Postal Code *</label>
|
||||||
|
<input type="text"
|
||||||
|
id="inline-sender-postal-code"
|
||||||
|
name="sender-postal-code"
|
||||||
|
pattern="[A-Za-z]\\d[A-Za-z]\\s?\\d[A-Za-z]\\d"
|
||||||
|
placeholder="e.g., T5N 4B8"
|
||||||
|
required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-email-subject">Subject *</label>
|
||||||
|
<input type="text" id="inline-email-subject" name="email-subject" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="inline-email-message">Your Message *</label>
|
||||||
|
<textarea id="inline-email-message" name="email-message" rows="8" maxlength="5000" required></textarea>
|
||||||
|
<span class="char-counter inline-char-counter">5000 characters remaining</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary inline-cancel">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Preview & Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendFromInlinePreview(sendBtn, composerDiv) {
|
||||||
|
if (!this.currentEmailData) {
|
||||||
|
window.messageDisplay.show('No email data to send', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalText = sendBtn.textContent;
|
||||||
|
|
||||||
|
try {
|
||||||
|
sendBtn.disabled = true;
|
||||||
|
sendBtn.textContent = 'Sending...';
|
||||||
|
|
||||||
|
const result = await window.apiClient.sendEmail(this.currentEmailData);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success', 7000);
|
||||||
|
|
||||||
|
// Close the inline composer after successful send
|
||||||
|
this.closeInlineComposer();
|
||||||
|
this.currentEmailData = null;
|
||||||
|
this.currentRecipient = null;
|
||||||
|
} else {
|
||||||
|
throw new Error(result.message || 'Failed to send email');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Email send failed:', error);
|
||||||
|
|
||||||
|
// Handle rate limit errors specifically
|
||||||
|
if (error.message && error.message.includes('rate limit')) {
|
||||||
|
window.messageDisplay.show('You are sending emails too quickly. Please wait a few minutes and try again.', 'error', 8000);
|
||||||
|
} else {
|
||||||
|
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error', 6000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sendBtn.disabled = false;
|
||||||
|
sendBtn.textContent = originalText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openModalDialog(recipient) {
|
||||||
this.currentRecipient = recipient;
|
this.currentRecipient = recipient;
|
||||||
|
|
||||||
// Populate recipient info
|
// Populate recipient info
|
||||||
|
|||||||
@ -354,43 +354,49 @@ class RepresentativesDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
attachEventListeners() {
|
attachEventListeners() {
|
||||||
// Add event listeners for compose email buttons
|
// Email compose buttons
|
||||||
const composeButtons = this.container.querySelectorAll('.compose-email');
|
const composeButtons = this.container.querySelectorAll('.compose-email');
|
||||||
composeButtons.forEach(button => {
|
composeButtons.forEach(button => {
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
const email = e.target.dataset.email;
|
e.preventDefault();
|
||||||
const name = e.target.dataset.name;
|
const email = button.dataset.email;
|
||||||
const office = e.target.dataset.office;
|
const name = button.dataset.name;
|
||||||
const district = e.target.dataset.district;
|
const office = button.dataset.office;
|
||||||
|
const district = button.dataset.district;
|
||||||
|
|
||||||
window.emailComposer.openModal({
|
// Find the closest rep-card ancestor
|
||||||
email,
|
const repCard = button.closest('.rep-card');
|
||||||
name,
|
|
||||||
office,
|
if (window.emailComposer) {
|
||||||
district
|
window.emailComposer.openModal({
|
||||||
});
|
email,
|
||||||
|
name,
|
||||||
|
office,
|
||||||
|
district
|
||||||
|
}, repCard); // Pass the card element
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add event listeners for call buttons
|
// Call buttons
|
||||||
const callButtons = this.container.querySelectorAll('.call-representative');
|
const callButtons = this.container.querySelectorAll('.call-representative');
|
||||||
callButtons.forEach(button => {
|
callButtons.forEach(button => {
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
const phone = e.target.dataset.phone;
|
e.preventDefault();
|
||||||
const name = e.target.dataset.name;
|
const phone = button.dataset.phone;
|
||||||
const office = e.target.dataset.office;
|
const name = button.dataset.name;
|
||||||
const officeType = e.target.dataset.officeType;
|
const office = button.dataset.office;
|
||||||
|
const officeType = button.dataset.officeType || '';
|
||||||
|
|
||||||
this.handleCallClick(phone, name, office, officeType);
|
this.handleCallClick(phone, name, office, officeType);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add event listeners for visit buttons
|
// Visit buttons (for office addresses)
|
||||||
const visitButtons = this.container.querySelectorAll('.visit-office');
|
const visitButtons = this.container.querySelectorAll('.visit-office');
|
||||||
visitButtons.forEach(button => {
|
visitButtons.forEach(button => {
|
||||||
button.addEventListener('click', (e) => {
|
button.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Use currentTarget to ensure we get the button, not nested elements
|
|
||||||
const address = button.dataset.address;
|
const address = button.dataset.address;
|
||||||
const name = button.dataset.name;
|
const name = button.dataset.name;
|
||||||
const office = button.dataset.office;
|
const office = button.dataset.office;
|
||||||
@ -398,15 +404,14 @@ class RepresentativesDisplay {
|
|||||||
this.handleVisitClick(address, name, office);
|
this.handleVisitClick(address, name, office);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add event listeners for image error handling
|
// Photo error handling (fallback to initials)
|
||||||
const repImages = this.container.querySelectorAll('.rep-photo img');
|
const photos = this.container.querySelectorAll('.rep-photo img');
|
||||||
repImages.forEach(img => {
|
photos.forEach(img => {
|
||||||
img.addEventListener('error', (e) => {
|
img.addEventListener('error', function() {
|
||||||
// Hide the image and show the fallback
|
this.style.display = 'none';
|
||||||
e.target.style.display = 'none';
|
const fallback = this.parentElement.querySelector('.rep-photo-fallback');
|
||||||
const fallback = e.target.nextElementSibling;
|
if (fallback) {
|
||||||
if (fallback && fallback.classList.contains('rep-photo-fallback')) {
|
|
||||||
fallback.style.display = 'flex';
|
fallback.style.display = 'flex';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
365
influence/app/public/js/response-wall.js
Normal file
365
influence/app/public/js/response-wall.js
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
// Response Wall JavaScript
|
||||||
|
|
||||||
|
let currentCampaignSlug = null;
|
||||||
|
let currentOffset = 0;
|
||||||
|
let currentSort = 'recent';
|
||||||
|
let currentLevel = '';
|
||||||
|
const LIMIT = 20;
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
console.log('Response Wall: Initializing...');
|
||||||
|
|
||||||
|
// Get campaign slug from URL if present
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
currentCampaignSlug = urlParams.get('campaign');
|
||||||
|
|
||||||
|
console.log('Campaign slug:', currentCampaignSlug);
|
||||||
|
|
||||||
|
if (!currentCampaignSlug) {
|
||||||
|
showError('No campaign specified');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
loadResponseStats();
|
||||||
|
loadResponses(true);
|
||||||
|
|
||||||
|
// Set up event listeners
|
||||||
|
document.getElementById('sort-select').addEventListener('change', (e) => {
|
||||||
|
currentSort = e.target.value;
|
||||||
|
loadResponses(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('level-filter').addEventListener('change', (e) => {
|
||||||
|
currentLevel = e.target.value;
|
||||||
|
loadResponses(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('submit-response-btn');
|
||||||
|
console.log('Submit button found:', !!submitBtn);
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.addEventListener('click', () => {
|
||||||
|
console.log('Submit button clicked');
|
||||||
|
openSubmitModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use event delegation for empty state button since it's dynamically shown
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'empty-state-submit-btn') {
|
||||||
|
console.log('Empty state button clicked');
|
||||||
|
openSubmitModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const modalCloseBtn = document.getElementById('modal-close-btn');
|
||||||
|
if (modalCloseBtn) {
|
||||||
|
modalCloseBtn.addEventListener('click', closeSubmitModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelBtn = document.getElementById('cancel-submit-btn');
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener('click', closeSubmitModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||||
|
if (loadMoreBtn) {
|
||||||
|
loadMoreBtn.addEventListener('click', loadMoreResponses);
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = document.getElementById('submit-response-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', handleSubmitResponse);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Response Wall: Initialization complete');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load response statistics
|
||||||
|
async function loadResponseStats() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
|
||||||
|
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
|
||||||
|
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
|
||||||
|
document.getElementById('stats-banner').style.display = 'flex';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading stats:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load responses
|
||||||
|
async function loadResponses(reset = false) {
|
||||||
|
if (reset) {
|
||||||
|
currentOffset = 0;
|
||||||
|
document.getElementById('responses-container').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
sort: currentSort,
|
||||||
|
level: currentLevel,
|
||||||
|
offset: currentOffset,
|
||||||
|
limit: LIMIT
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
showLoading(false);
|
||||||
|
|
||||||
|
if (data.success && data.responses.length > 0) {
|
||||||
|
renderResponses(data.responses);
|
||||||
|
|
||||||
|
// Show/hide load more button
|
||||||
|
if (data.pagination.hasMore) {
|
||||||
|
document.getElementById('load-more-container').style.display = 'block';
|
||||||
|
} else {
|
||||||
|
document.getElementById('load-more-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
} else if (reset) {
|
||||||
|
showEmptyState();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showLoading(false);
|
||||||
|
showError('Failed to load responses');
|
||||||
|
console.error('Error loading responses:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render responses
|
||||||
|
function renderResponses(responses) {
|
||||||
|
const container = document.getElementById('responses-container');
|
||||||
|
|
||||||
|
responses.forEach(response => {
|
||||||
|
const card = createResponseCard(response);
|
||||||
|
container.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create response card element
|
||||||
|
function createResponseCard(response) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'response-card';
|
||||||
|
card.dataset.responseId = response.id;
|
||||||
|
|
||||||
|
const createdDate = new Date(response.created_at).toLocaleDateString('en-US', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
let badges = `<span class="badge badge-level">${escapeHtml(response.representative_level)}</span>`;
|
||||||
|
badges += `<span class="badge badge-type">${escapeHtml(response.response_type)}</span>`;
|
||||||
|
if (response.is_verified) {
|
||||||
|
badges = `<span class="badge badge-verified">✓ Verified</span>` + badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
let submittedBy = 'Anonymous';
|
||||||
|
if (!response.is_anonymous && response.submitted_by_name) {
|
||||||
|
submittedBy = escapeHtml(response.submitted_by_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userCommentHtml = '';
|
||||||
|
if (response.user_comment) {
|
||||||
|
userCommentHtml = `
|
||||||
|
<div class="user-comment">
|
||||||
|
<span class="user-comment-label">Constituent's Comment:</span>
|
||||||
|
${escapeHtml(response.user_comment)}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let screenshotHtml = '';
|
||||||
|
if (response.screenshot_url) {
|
||||||
|
screenshotHtml = `
|
||||||
|
<div class="response-screenshot">
|
||||||
|
<img src="${escapeHtml(response.screenshot_url)}"
|
||||||
|
alt="Response screenshot"
|
||||||
|
data-image-url="${escapeHtml(response.screenshot_url)}"
|
||||||
|
class="screenshot-image">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="response-header">
|
||||||
|
<div class="response-rep-info">
|
||||||
|
<h3>${escapeHtml(response.representative_name)}</h3>
|
||||||
|
<div class="rep-meta">
|
||||||
|
${response.representative_title ? `<span>${escapeHtml(response.representative_title)}</span>` : ''}
|
||||||
|
<span>${createdDate}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="response-badges">
|
||||||
|
${badges}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-content">
|
||||||
|
<div class="response-text">${escapeHtml(response.response_text)}</div>
|
||||||
|
${userCommentHtml}
|
||||||
|
${screenshotHtml}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="response-footer">
|
||||||
|
<div class="response-meta">
|
||||||
|
Submitted by ${submittedBy}
|
||||||
|
</div>
|
||||||
|
<div class="response-actions">
|
||||||
|
<button class="upvote-btn ${upvoteClass}" data-response-id="${response.id}">
|
||||||
|
<span class="upvote-icon">👍</span>
|
||||||
|
<span class="upvote-count">${response.upvote_count || 0}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listener for upvote button
|
||||||
|
const upvoteBtn = card.querySelector('.upvote-btn');
|
||||||
|
upvoteBtn.addEventListener('click', function() {
|
||||||
|
toggleUpvote(response.id, this);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add event listener for screenshot image if present
|
||||||
|
const screenshotImg = card.querySelector('.screenshot-image');
|
||||||
|
if (screenshotImg) {
|
||||||
|
screenshotImg.addEventListener('click', function() {
|
||||||
|
viewImage(this.dataset.imageUrl);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle upvote
|
||||||
|
async function toggleUpvote(responseId, button) {
|
||||||
|
const isUpvoted = button.classList.contains('upvoted');
|
||||||
|
const url = `/api/responses/${responseId}/upvote`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: isUpvoted ? 'DELETE' : 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
// Update button state
|
||||||
|
button.classList.toggle('upvoted');
|
||||||
|
button.querySelector('.upvote-count').textContent = data.upvoteCount;
|
||||||
|
|
||||||
|
// Reload stats
|
||||||
|
loadResponseStats();
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Failed to update upvote');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling upvote:', error);
|
||||||
|
showError('Failed to update upvote');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load more responses
|
||||||
|
function loadMoreResponses() {
|
||||||
|
currentOffset += LIMIT;
|
||||||
|
loadResponses(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open submit modal
|
||||||
|
function openSubmitModal() {
|
||||||
|
console.log('openSubmitModal called');
|
||||||
|
const modal = document.getElementById('submit-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.style.display = 'block';
|
||||||
|
console.log('Modal displayed');
|
||||||
|
} else {
|
||||||
|
console.error('Modal element not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close submit modal
|
||||||
|
function closeSubmitModal() {
|
||||||
|
document.getElementById('submit-modal').style.display = 'none';
|
||||||
|
document.getElementById('submit-response-form').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle response submission
|
||||||
|
async function handleSubmitResponse(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.');
|
||||||
|
closeSubmitModal();
|
||||||
|
// Don't reload responses since submission is pending approval
|
||||||
|
} else {
|
||||||
|
showError(data.error || 'Failed to submit response');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error submitting response:', error);
|
||||||
|
showError('Failed to submit response');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View image in modal/new tab
|
||||||
|
function viewImage(url) {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function showLoading(show) {
|
||||||
|
document.getElementById('loading').style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showEmptyState() {
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
document.getElementById('responses-container').innerHTML = '';
|
||||||
|
document.getElementById('load-more-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError(message) {
|
||||||
|
// Simple alert for now - could integrate with existing error display system
|
||||||
|
alert('Error: ' + message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess(message) {
|
||||||
|
// Simple alert for now - could integrate with existing success display system
|
||||||
|
alert(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('submit-modal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeSubmitModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
163
influence/app/public/response-wall.html
Normal file
163
influence/app/public/response-wall.html
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Response Wall | BNKops Influence</title>
|
||||||
|
<link rel="stylesheet" href="/css/styles.css">
|
||||||
|
<link rel="stylesheet" href="/css/response-wall.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1>📢 Community Response Wall</h1>
|
||||||
|
<p>See what representatives are saying back to constituents</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Stats Banner -->
|
||||||
|
<div class="stats-banner" id="stats-banner" style="display: none;">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="stat-total-responses">0</span>
|
||||||
|
<span class="stat-label">Total Responses</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="stat-verified">0</span>
|
||||||
|
<span class="stat-label">Verified</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="stat-upvotes">0</span>
|
||||||
|
<span class="stat-label">Total Upvotes</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="response-controls">
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="sort-select">Sort by:</label>
|
||||||
|
<select id="sort-select">
|
||||||
|
<option value="recent">Most Recent</option>
|
||||||
|
<option value="upvotes">Most Upvoted</option>
|
||||||
|
<option value="verified">Verified First</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<label for="level-filter">Filter by Level:</label>
|
||||||
|
<select id="level-filter">
|
||||||
|
<option value="">All Levels</option>
|
||||||
|
<option value="Federal">Federal</option>
|
||||||
|
<option value="Provincial">Provincial</option>
|
||||||
|
<option value="Municipal">Municipal</option>
|
||||||
|
<option value="School Board">School Board</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" id="submit-response-btn">
|
||||||
|
✍️ Share a Response
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading Indicator -->
|
||||||
|
<div id="loading" class="loading" style="display: none;">
|
||||||
|
<p>Loading responses...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="empty-state" class="empty-state" style="display: none;">
|
||||||
|
<p>No responses yet. Be the first to share!</p>
|
||||||
|
<button class="btn btn-primary" id="empty-state-submit-btn">Share a Response</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Responses Container -->
|
||||||
|
<div id="responses-container"></div>
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="load-more-container" id="load-more-container" style="display: none;">
|
||||||
|
<button class="btn btn-secondary" id="load-more-btn">Load More</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Response Modal -->
|
||||||
|
<div id="submit-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close" id="modal-close-btn">×</span>
|
||||||
|
<h2>Share a Representative Response</h2>
|
||||||
|
<form id="submit-response-form" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="representative-name">Representative Name *</label>
|
||||||
|
<input type="text" id="representative-name" name="representative_name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="representative-title">Representative Title</label>
|
||||||
|
<input type="text" id="representative-title" name="representative_title" placeholder="e.g., MLA, MP, Councillor">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="representative-level">Government Level *</label>
|
||||||
|
<select id="representative-level" name="representative_level" required>
|
||||||
|
<option value="">Select level...</option>
|
||||||
|
<option value="Federal">Federal</option>
|
||||||
|
<option value="Provincial">Provincial</option>
|
||||||
|
<option value="Municipal">Municipal</option>
|
||||||
|
<option value="School Board">School Board</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="response-type">Response Type *</label>
|
||||||
|
<select id="response-type" name="response_type" required>
|
||||||
|
<option value="">Select type...</option>
|
||||||
|
<option value="Email">Email</option>
|
||||||
|
<option value="Letter">Letter</option>
|
||||||
|
<option value="Phone Call">Phone Call</option>
|
||||||
|
<option value="Meeting">Meeting</option>
|
||||||
|
<option value="Social Media">Social Media</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="response-text">Response Text *</label>
|
||||||
|
<textarea id="response-text" name="response_text" rows="6" required placeholder="Paste or type the response you received..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="user-comment">Your Comment (Optional)</label>
|
||||||
|
<textarea id="user-comment" name="user_comment" rows="3" placeholder="Add your thoughts about this response..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="screenshot">Screenshot (Optional)</label>
|
||||||
|
<input type="file" id="screenshot" name="screenshot" accept="image/*">
|
||||||
|
<small>Upload a screenshot of the response (max 5MB)</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="submitted-by-name">Your Name (Optional)</label>
|
||||||
|
<input type="text" id="submitted-by-name" name="submitted_by_name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="submitted-by-email">Your Email (Optional)</label>
|
||||||
|
<input type="email" id="submitted-by-email" name="submitted_by_email">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="is-anonymous" name="is_anonymous">
|
||||||
|
Post anonymously (don't show my name)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Submit Response</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/js/response-wall.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -4,8 +4,10 @@ const { body, param, validationResult } = require('express-validator');
|
|||||||
const representativesController = require('../controllers/representatives');
|
const representativesController = require('../controllers/representatives');
|
||||||
const emailsController = require('../controllers/emails');
|
const emailsController = require('../controllers/emails');
|
||||||
const campaignsController = require('../controllers/campaigns');
|
const campaignsController = require('../controllers/campaigns');
|
||||||
|
const responsesController = require('../controllers/responses');
|
||||||
const rateLimiter = require('../utils/rate-limiter');
|
const rateLimiter = require('../utils/rate-limiter');
|
||||||
const { requireAdmin, requireAuth, requireNonTemp } = require('../middleware/auth');
|
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
|
||||||
|
const upload = require('../middleware/upload');
|
||||||
|
|
||||||
// Import user routes
|
// Import user routes
|
||||||
const userRoutes = require('./users');
|
const userRoutes = require('./users');
|
||||||
@ -192,4 +194,51 @@ router.post(
|
|||||||
// User management routes (admin only)
|
// User management routes (admin only)
|
||||||
router.use('/admin/users', userRoutes);
|
router.use('/admin/users', userRoutes);
|
||||||
|
|
||||||
|
// Response Wall Routes
|
||||||
|
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
|
||||||
|
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
|
||||||
|
router.post(
|
||||||
|
'/campaigns/:slug/responses',
|
||||||
|
optionalAuth,
|
||||||
|
upload.single('screenshot'),
|
||||||
|
rateLimiter.general,
|
||||||
|
[
|
||||||
|
body('representative_name').notEmpty().withMessage('Representative name is required'),
|
||||||
|
body('representative_level').isIn(['Federal', 'Provincial', 'Municipal', 'School Board']).withMessage('Invalid representative level'),
|
||||||
|
body('response_type').isIn(['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']).withMessage('Invalid response type'),
|
||||||
|
body('response_text').notEmpty().withMessage('Response text is required')
|
||||||
|
],
|
||||||
|
handleValidationErrors,
|
||||||
|
responsesController.submitResponse
|
||||||
|
);
|
||||||
|
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,
|
||||||
|
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
|
||||||
|
handleValidationErrors,
|
||||||
|
responsesController.updateResponseStatus
|
||||||
|
);
|
||||||
|
router.patch('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.updateResponse);
|
||||||
|
router.delete('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.deleteResponse);
|
||||||
|
|
||||||
|
// Debug endpoint to check raw NocoDB data
|
||||||
|
router.get('/debug/responses', requireAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const nocodbService = require('../services/nocodb');
|
||||||
|
// Get raw data without normalization
|
||||||
|
const rawResult = await nocodbService.getAll(nocodbService.tableIds.representativeResponses, {});
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
count: rawResult.list?.length || 0,
|
||||||
|
rawResponses: rawResult.list || [],
|
||||||
|
normalized: await nocodbService.getRepresentativeResponses({})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message, stack: error.stack });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@ -26,7 +26,9 @@ class NocoDBService {
|
|||||||
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
|
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
|
||||||
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
|
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
|
||||||
users: process.env.NOCODB_TABLE_USERS,
|
users: process.env.NOCODB_TABLE_USERS,
|
||||||
calls: process.env.NOCODB_TABLE_CALLS
|
calls: process.env.NOCODB_TABLE_CALLS,
|
||||||
|
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
|
||||||
|
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate that all table IDs are set
|
// Validate that all table IDs are set
|
||||||
@ -688,6 +690,191 @@ class NocoDBService {
|
|||||||
|
|
||||||
return await this.getAll(this.tableIds.users, params);
|
return await this.getAll(this.tableIds.users, params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Representative Responses methods
|
||||||
|
async getRepresentativeResponses(params = {}) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
console.log('getRepresentativeResponses params:', JSON.stringify(params, null, 2));
|
||||||
|
const result = await this.getAll(this.tableIds.representativeResponses, params);
|
||||||
|
|
||||||
|
// Log without the where clause to see ALL responses
|
||||||
|
if (params.where) {
|
||||||
|
const allResult = await this.getAll(this.tableIds.representativeResponses, {});
|
||||||
|
console.log(`Total responses in DB (no filter): ${allResult.list?.length || 0}`);
|
||||||
|
if (allResult.list && allResult.list.length > 0) {
|
||||||
|
console.log('Sample raw response from DB:', JSON.stringify(allResult.list[0], null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('getRepresentativeResponses raw result:', JSON.stringify(result, null, 2));
|
||||||
|
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
|
||||||
|
const list = result.list || [];
|
||||||
|
console.log(`getRepresentativeResponses: Found ${list.length} responses`);
|
||||||
|
return list.map(item => this.normalizeResponse(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRepresentativeResponseById(responseId) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
|
||||||
|
const response = await this.client.get(url);
|
||||||
|
return this.normalizeResponse(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error getting representative response by ID:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRepresentativeResponse(responseData) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure campaign_id is not null/undefined
|
||||||
|
if (!responseData.campaign_id) {
|
||||||
|
throw new Error('Campaign ID is required for creating a response');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
'Campaign ID': responseData.campaign_id,
|
||||||
|
'Campaign Slug': responseData.campaign_slug,
|
||||||
|
'Representative Name': responseData.representative_name,
|
||||||
|
'Representative Title': responseData.representative_title,
|
||||||
|
'Representative Level': responseData.representative_level,
|
||||||
|
'Response Type': responseData.response_type,
|
||||||
|
'Response Text': responseData.response_text,
|
||||||
|
'User Comment': responseData.user_comment,
|
||||||
|
'Screenshot URL': responseData.screenshot_url,
|
||||||
|
'Submitted By Name': responseData.submitted_by_name,
|
||||||
|
'Submitted By Email': responseData.submitted_by_email,
|
||||||
|
'Submitted By User ID': responseData.submitted_by_user_id,
|
||||||
|
'Is Anonymous': responseData.is_anonymous,
|
||||||
|
'Status': responseData.status,
|
||||||
|
'Is Verified': responseData.is_verified,
|
||||||
|
'Upvote Count': responseData.upvote_count,
|
||||||
|
'Submitted IP': responseData.submitted_ip
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log('Creating response with data:', JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const url = this.getTableUrl(this.tableIds.representativeResponses);
|
||||||
|
const response = await this.client.post(url, data);
|
||||||
|
return this.normalizeResponse(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRepresentativeResponse(responseId, updates) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {};
|
||||||
|
if (updates.status !== undefined) data['Status'] = updates.status;
|
||||||
|
if (updates.is_verified !== undefined) data['Is Verified'] = updates.is_verified;
|
||||||
|
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
|
||||||
|
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
|
||||||
|
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
|
||||||
|
|
||||||
|
console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
|
||||||
|
const response = await this.client.patch(url, data);
|
||||||
|
|
||||||
|
console.log('NocoDB update response:', JSON.stringify(response.data, null, 2));
|
||||||
|
|
||||||
|
return this.normalizeResponse(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRepresentativeResponse(responseId) {
|
||||||
|
if (!this.tableIds.representativeResponses) {
|
||||||
|
throw new Error('Representative responses table not configured');
|
||||||
|
}
|
||||||
|
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
|
||||||
|
const response = await this.client.delete(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response Upvotes methods
|
||||||
|
async getResponseUpvotes(params = {}) {
|
||||||
|
if (!this.tableIds.responseUpvotes) {
|
||||||
|
throw new Error('Response upvotes table not configured');
|
||||||
|
}
|
||||||
|
const result = await this.getAll(this.tableIds.responseUpvotes, params);
|
||||||
|
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
|
||||||
|
const list = result.list || [];
|
||||||
|
return list.map(item => this.normalizeUpvote(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createResponseUpvote(upvoteData) {
|
||||||
|
if (!this.tableIds.responseUpvotes) {
|
||||||
|
throw new Error('Response upvotes table not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
'Response ID': upvoteData.response_id,
|
||||||
|
'User ID': upvoteData.user_id,
|
||||||
|
'User Email': upvoteData.user_email,
|
||||||
|
'Upvoted IP': upvoteData.upvoted_ip
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = this.getTableUrl(this.tableIds.responseUpvotes);
|
||||||
|
const response = await this.client.post(url, data);
|
||||||
|
return this.normalizeUpvote(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteResponseUpvote(upvoteId) {
|
||||||
|
if (!this.tableIds.responseUpvotes) {
|
||||||
|
throw new Error('Response upvotes table not configured');
|
||||||
|
}
|
||||||
|
const url = `${this.getTableUrl(this.tableIds.responseUpvotes)}/${upvoteId}`;
|
||||||
|
const response = await this.client.delete(url);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize response data from NocoDB format to application format
|
||||||
|
normalizeResponse(data) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.ID || data.Id || data.id,
|
||||||
|
campaign_id: data['Campaign ID'] || data.campaign_id,
|
||||||
|
campaign_slug: data['Campaign Slug'] || data.campaign_slug,
|
||||||
|
representative_name: data['Representative Name'] || data.representative_name,
|
||||||
|
representative_title: data['Representative Title'] || data.representative_title,
|
||||||
|
representative_level: data['Representative Level'] || data.representative_level,
|
||||||
|
response_type: data['Response Type'] || data.response_type,
|
||||||
|
response_text: data['Response Text'] || data.response_text,
|
||||||
|
user_comment: data['User Comment'] || data.user_comment,
|
||||||
|
screenshot_url: data['Screenshot URL'] || data.screenshot_url,
|
||||||
|
submitted_by_name: data['Submitted By Name'] || data.submitted_by_name,
|
||||||
|
submitted_by_email: data['Submitted By Email'] || data.submitted_by_email,
|
||||||
|
submitted_by_user_id: data['Submitted By User ID'] || data.submitted_by_user_id,
|
||||||
|
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
|
||||||
|
status: data['Status'] || data.status,
|
||||||
|
is_verified: data['Is Verified'] || data.is_verified || false,
|
||||||
|
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
|
||||||
|
submitted_ip: data['Submitted IP'] || data.submitted_ip,
|
||||||
|
created_at: data.CreatedAt || data.created_at,
|
||||||
|
updated_at: data.UpdatedAt || data.updated_at
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize upvote data from NocoDB format to application format
|
||||||
|
normalizeUpvote(data) {
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: data.ID || data.Id || data.id,
|
||||||
|
response_id: data['Response ID'] || data.response_id,
|
||||||
|
user_id: data['User ID'] || data.user_id,
|
||||||
|
user_email: data['User Email'] || data.user_email,
|
||||||
|
upvoted_ip: data['Upvoted IP'] || data.upvoted_ip,
|
||||||
|
created_at: data.CreatedAt || data.created_at
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new NocoDBService();
|
module.exports = new NocoDBService();
|
||||||
|
|||||||
@ -78,6 +78,52 @@ function validateSlug(slug) {
|
|||||||
return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100;
|
return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate response submission
|
||||||
|
function validateResponse(data) {
|
||||||
|
const { representative_name, representative_level, response_type, response_text } = data;
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if (!representative_name || representative_name.trim() === '') {
|
||||||
|
return { valid: false, error: 'Representative name is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!representative_level || representative_level.trim() === '') {
|
||||||
|
return { valid: false, error: 'Representative level is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate representative level
|
||||||
|
const validLevels = ['Federal', 'Provincial', 'Municipal', 'School Board'];
|
||||||
|
if (!validLevels.includes(representative_level)) {
|
||||||
|
return { valid: false, error: 'Invalid representative level' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response_type || response_type.trim() === '') {
|
||||||
|
return { valid: false, error: 'Response type is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate response type
|
||||||
|
const validTypes = ['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other'];
|
||||||
|
if (!validTypes.includes(response_type)) {
|
||||||
|
return { valid: false, error: 'Invalid response type' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response_text || response_text.trim() === '') {
|
||||||
|
return { valid: false, error: 'Response text is required' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for suspicious content
|
||||||
|
if (containsSuspiciousContent(response_text)) {
|
||||||
|
return { valid: false, error: 'Response contains invalid content' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate email if provided
|
||||||
|
if (data.submitted_by_email && !validateEmail(data.submitted_by_email)) {
|
||||||
|
return { valid: false, error: 'Invalid email address' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
validatePostalCode,
|
validatePostalCode,
|
||||||
validateAlbertaPostalCode,
|
validateAlbertaPostalCode,
|
||||||
@ -87,5 +133,6 @@ module.exports = {
|
|||||||
validateRequiredFields,
|
validateRequiredFields,
|
||||||
containsSuspiciousContent,
|
containsSuspiciousContent,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
validateSlug
|
validateSlug,
|
||||||
|
validateResponse
|
||||||
};
|
};
|
||||||
@ -1330,6 +1330,201 @@ create_call_logs_table() {
|
|||||||
create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
|
create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Function to create the representative responses table
|
||||||
|
create_representative_responses_table() {
|
||||||
|
local base_id=$1
|
||||||
|
|
||||||
|
local table_data='{
|
||||||
|
"table_name": "influence_representative_responses",
|
||||||
|
"title": "Representative Responses",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"column_name": "id",
|
||||||
|
"title": "ID",
|
||||||
|
"uidt": "ID",
|
||||||
|
"pk": true,
|
||||||
|
"ai": true,
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "campaign_id",
|
||||||
|
"title": "Campaign ID",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "campaign_slug",
|
||||||
|
"title": "Campaign Slug",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "representative_name",
|
||||||
|
"title": "Representative Name",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "representative_title",
|
||||||
|
"title": "Representative Title",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "representative_level",
|
||||||
|
"title": "Representative Level",
|
||||||
|
"uidt": "SingleSelect",
|
||||||
|
"rqd": true,
|
||||||
|
"colOptions": {
|
||||||
|
"options": [
|
||||||
|
{"title": "Federal", "color": "#e74c3c"},
|
||||||
|
{"title": "Provincial", "color": "#3498db"},
|
||||||
|
{"title": "Municipal", "color": "#2ecc71"},
|
||||||
|
{"title": "School Board", "color": "#f39c12"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "response_type",
|
||||||
|
"title": "Response Type",
|
||||||
|
"uidt": "SingleSelect",
|
||||||
|
"rqd": true,
|
||||||
|
"colOptions": {
|
||||||
|
"options": [
|
||||||
|
{"title": "Email", "color": "#3498db"},
|
||||||
|
{"title": "Letter", "color": "#9b59b6"},
|
||||||
|
{"title": "Phone Call", "color": "#1abc9c"},
|
||||||
|
{"title": "Meeting", "color": "#e67e22"},
|
||||||
|
{"title": "Social Media", "color": "#34495e"},
|
||||||
|
{"title": "Other", "color": "#95a5a6"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "response_text",
|
||||||
|
"title": "Response Text",
|
||||||
|
"uidt": "LongText",
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "user_comment",
|
||||||
|
"title": "User Comment",
|
||||||
|
"uidt": "LongText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "screenshot_url",
|
||||||
|
"title": "Screenshot URL",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "submitted_by_name",
|
||||||
|
"title": "Submitted By Name",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "submitted_by_email",
|
||||||
|
"title": "Submitted By Email",
|
||||||
|
"uidt": "Email",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "submitted_by_user_id",
|
||||||
|
"title": "Submitted By User ID",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "is_anonymous",
|
||||||
|
"title": "Is Anonymous",
|
||||||
|
"uidt": "Checkbox",
|
||||||
|
"cdf": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "status",
|
||||||
|
"title": "Status",
|
||||||
|
"uidt": "SingleSelect",
|
||||||
|
"cdf": "pending",
|
||||||
|
"colOptions": {
|
||||||
|
"options": [
|
||||||
|
{"title": "pending", "color": "#f39c12"},
|
||||||
|
{"title": "approved", "color": "#2ecc71"},
|
||||||
|
{"title": "rejected", "color": "#e74c3c"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "is_verified",
|
||||||
|
"title": "Is Verified",
|
||||||
|
"uidt": "Checkbox",
|
||||||
|
"cdf": "false"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "upvote_count",
|
||||||
|
"title": "Upvote Count",
|
||||||
|
"uidt": "Number",
|
||||||
|
"cdf": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "submitted_ip",
|
||||||
|
"title": "Submitted IP",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
create_table "$base_id" "influence_representative_responses" "$table_data" "Community responses from representatives"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to create the response upvotes table
|
||||||
|
create_response_upvotes_table() {
|
||||||
|
local base_id=$1
|
||||||
|
|
||||||
|
local table_data='{
|
||||||
|
"table_name": "influence_response_upvotes",
|
||||||
|
"title": "Response Upvotes",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"column_name": "id",
|
||||||
|
"title": "ID",
|
||||||
|
"uidt": "ID",
|
||||||
|
"pk": true,
|
||||||
|
"ai": true,
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "response_id",
|
||||||
|
"title": "Response ID",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "user_id",
|
||||||
|
"title": "User ID",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "user_email",
|
||||||
|
"title": "User Email",
|
||||||
|
"uidt": "Email",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"column_name": "upvoted_ip",
|
||||||
|
"title": "Upvoted IP",
|
||||||
|
"uidt": "SingleLineText",
|
||||||
|
"rqd": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
|
||||||
|
create_table "$base_id" "influence_response_upvotes" "$table_data" "Track upvotes on responses"
|
||||||
|
}
|
||||||
|
|
||||||
# Function to create the users table
|
# Function to create the users table
|
||||||
create_users_table() {
|
create_users_table() {
|
||||||
local base_id="$1"
|
local base_id="$1"
|
||||||
@ -1451,6 +1646,8 @@ update_env_with_table_ids() {
|
|||||||
local campaign_emails_table_id=$6
|
local campaign_emails_table_id=$6
|
||||||
local users_table_id=$7
|
local users_table_id=$7
|
||||||
local call_logs_table_id=$8
|
local call_logs_table_id=$8
|
||||||
|
local representative_responses_table_id=$9
|
||||||
|
local response_upvotes_table_id=${10}
|
||||||
|
|
||||||
print_status "Updating .env file with NocoDB project and table IDs..."
|
print_status "Updating .env file with NocoDB project and table IDs..."
|
||||||
|
|
||||||
@ -1486,6 +1683,8 @@ update_env_with_table_ids() {
|
|||||||
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
|
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
|
||||||
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
|
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
|
||||||
update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
|
update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
|
||||||
|
update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
|
||||||
|
update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id"
|
||||||
|
|
||||||
print_success "Successfully updated .env file with all table IDs"
|
print_success "Successfully updated .env file with all table IDs"
|
||||||
|
|
||||||
@ -1500,6 +1699,8 @@ update_env_with_table_ids() {
|
|||||||
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
|
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
|
||||||
print_status "NOCODB_TABLE_USERS=$users_table_id"
|
print_status "NOCODB_TABLE_USERS=$users_table_id"
|
||||||
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
|
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
|
||||||
|
print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id"
|
||||||
|
print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1607,8 +1808,22 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Create representative responses table
|
||||||
|
REPRESENTATIVE_RESPONSES_TABLE_ID=$(create_representative_responses_table "$BASE_ID")
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
print_error "Failed to create representative responses table"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create response upvotes table
|
||||||
|
RESPONSE_UPVOTES_TABLE_ID=$(create_response_upvotes_table "$BASE_ID")
|
||||||
|
if [[ $? -ne 0 ]]; then
|
||||||
|
print_error "Failed to create response upvotes table"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Validate all table IDs were created successfully
|
# Validate all table IDs were created successfully
|
||||||
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then
|
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"; then
|
||||||
print_error "One or more table IDs are invalid"
|
print_error "One or more table IDs are invalid"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -1630,6 +1845,8 @@ main() {
|
|||||||
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
|
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
|
||||||
table_mapping["influence_users"]="$USERS_TABLE_ID"
|
table_mapping["influence_users"]="$USERS_TABLE_ID"
|
||||||
table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
|
table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
|
||||||
|
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
|
||||||
|
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
|
||||||
|
|
||||||
# Get source table information
|
# Get source table information
|
||||||
local source_tables_response
|
local source_tables_response
|
||||||
@ -1682,6 +1899,8 @@ main() {
|
|||||||
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
|
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
|
||||||
print_status " - influence_users (ID: $USERS_TABLE_ID)"
|
print_status " - influence_users (ID: $USERS_TABLE_ID)"
|
||||||
print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
|
print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
|
||||||
|
print_status " - influence_representative_responses (ID: $REPRESENTATIVE_RESPONSES_TABLE_ID)"
|
||||||
|
print_status " - influence_response_upvotes (ID: $RESPONSE_UPVOTES_TABLE_ID)"
|
||||||
|
|
||||||
# Automatically update .env file with new project ID
|
# Automatically update .env file with new project ID
|
||||||
print_status ""
|
print_status ""
|
||||||
@ -1704,7 +1923,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Update .env file with table IDs
|
# Update .env file with table IDs
|
||||||
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"
|
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"
|
||||||
|
|
||||||
print_status ""
|
print_status ""
|
||||||
print_status "============================================================"
|
print_status "============================================================"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user