1350 lines
34 KiB
Markdown
1350 lines
34 KiB
Markdown
# Response Wall Page
|
|
|
|
## Overview
|
|
|
|
**File Path:** `admin/src/pages/public/ResponseWallPage.tsx` (492 lines)
|
|
|
|
**Route:** `/responses/:campaignId`
|
|
|
|
**Role Requirements:** Public access (no authentication required)
|
|
|
|
**Purpose:** Community-driven response wall displaying user-submitted campaign feedback, verification status, government official replies, and social engagement through upvoting. Serves as social proof and community building tool for advocacy campaigns.
|
|
|
|
**Key Features:**
|
|
|
|
- Campaign-specific response display with back navigation
|
|
- Real-time statistics cards (Total Responses, Verified, Total Upvotes)
|
|
- Multi-criteria sorting (Recent, Most Upvoted, Verified Only)
|
|
- Government level filtering (Federal, Provincial, Municipal, All)
|
|
- Response cards with upvote functionality
|
|
- User comments and representative details
|
|
- Verification badges for confirmed responses
|
|
- Response submission modal with long-form input
|
|
- Pagination for large response sets
|
|
- Dark blue/teal theme consistency
|
|
- Mobile-responsive grid layout
|
|
|
|
**Layout:** Uses `PublicLayout` component with dark theme
|
|
|
|
---
|
|
|
|
## Features
|
|
|
|
### 1. Campaign Context Header
|
|
|
|
Navigation and campaign identification:
|
|
|
|
- **Back Link**: Returns to campaign detail page (`/campaigns/:campaignId`)
|
|
- **Campaign Title**: Displays as page heading
|
|
- **Breadcrumb**: "Response Wall" subtitle
|
|
- **Icon**: Comment icon for visual context
|
|
|
|
### 2. Statistics Dashboard
|
|
|
|
Three key metrics displayed as cards:
|
|
|
|
- **Total Responses**: Count of all submissions (verified + unverified)
|
|
- **Verified Responses**: Count of email-verified submissions
|
|
- **Total Upvotes**: Aggregate upvote count across all responses
|
|
|
|
**Card Design:**
|
|
- Large numeric display (32px font)
|
|
- Icon with brand color
|
|
- Label text below number
|
|
- Responsive grid (xs=1, sm=3 columns)
|
|
- Hover effect for visual feedback
|
|
|
|
### 3. Filtering and Sorting Controls
|
|
|
|
User controls for response discovery:
|
|
|
|
**Sort Dropdown:**
|
|
- **Recent**: Newest first (default, `createdAt DESC`)
|
|
- **Most Upvoted**: Highest upvote count first (`upvoteCount DESC`)
|
|
- **Verified Only**: Only email-verified responses
|
|
|
|
**Government Level Filter:**
|
|
- **All Levels**: No filtering (default)
|
|
- **Federal**: Federal government responses only
|
|
- **Provincial**: Provincial/territorial responses only
|
|
- **Municipal**: Municipal/local responses only
|
|
|
|
**Layout:**
|
|
- Row with two columns
|
|
- Sort on left, filter on right
|
|
- Full-width selects on mobile
|
|
- Margin below for spacing
|
|
|
|
### 4. Response Cards
|
|
|
|
Individual response display with rich metadata:
|
|
|
|
**Card Header:**
|
|
- User name (bold, 16px)
|
|
- Timestamp (relative: "2 hours ago")
|
|
- Verification badge (if `isVerified=true`)
|
|
|
|
**Card Content:**
|
|
- User comment (full text, auto-wrapping)
|
|
- Quoted text if available (italicized, gray background)
|
|
- Representative details:
|
|
- Name (bold)
|
|
- District/riding
|
|
- Government level tag (colored by level)
|
|
|
|
**Card Footer:**
|
|
- Upvote button with count
|
|
- Heart icon (filled if user upvoted)
|
|
- Click toggles upvote status
|
|
- Optimistic UI update
|
|
|
|
**Styling:**
|
|
- Dark background (`colorBgContainer`)
|
|
- Rounded corners (8px)
|
|
- Hover elevation shadow
|
|
- Dividers between sections
|
|
|
|
### 5. Submit Response Modal
|
|
|
|
Long-form response submission interface:
|
|
|
|
**Form Fields:**
|
|
- **Your Name** (required, text input)
|
|
- **Your Email** (required, email validation)
|
|
- **Your Postal Code** (optional, for rep lookup context)
|
|
- **Representative** (read-only, from parent campaign context)
|
|
- **Your Comment** (required, TextArea, 5 rows min)
|
|
- **Email Me a Copy** (checkbox, default checked)
|
|
|
|
**Validation:**
|
|
- Required field indicators
|
|
- Email format validation
|
|
- Min/max length checks (comment: 10-5000 chars)
|
|
- Disabled submit until valid
|
|
|
|
**Submission Flow:**
|
|
1. User clicks "Submit Your Response" button
|
|
2. Modal opens with empty form
|
|
3. User fills fields
|
|
4. Clicks "Submit Response" button
|
|
5. API creates response (status: `unverified`)
|
|
6. Verification email sent if checkbox checked
|
|
7. Success modal displays
|
|
8. Form resets
|
|
9. Responses list refreshes
|
|
|
|
### 6. Pagination
|
|
|
|
Ant Design Pagination component:
|
|
|
|
- **Page Size**: 20 responses per page
|
|
- **Total Count**: Fetched from API
|
|
- **Page Change**: Triggers new API request
|
|
- **Positioning**: Centered below response grid
|
|
- **Styling**: Inherits dark theme from PublicLayout
|
|
|
|
---
|
|
|
|
## User Workflow
|
|
|
|
### Browsing Responses
|
|
|
|
1. User arrives from campaign page via "View Response Wall" link
|
|
2. Page loads responses (default: recent, all levels)
|
|
3. User views statistics cards showing community engagement
|
|
4. User scrolls through response cards
|
|
5. User reads comments and representative details
|
|
6. User upvotes responses they agree with
|
|
7. User clicks pagination to view more responses
|
|
|
|
### Filtering and Sorting
|
|
|
|
1. User selects "Most Upvoted" from sort dropdown
|
|
2. API re-fetches responses with new sort order
|
|
3. Grid updates with reordered responses
|
|
4. User selects "Federal" from government level filter
|
|
5. API re-fetches with government level filter
|
|
6. Grid shows only federal responses
|
|
7. User resets filters to "All Levels" to see everything
|
|
|
|
### Submitting a Response
|
|
|
|
1. User clicks "Submit Your Response" button
|
|
2. Modal opens with blank form
|
|
3. User enters name: "Jane Doe"
|
|
4. User enters email: "jane@example.com"
|
|
5. User enters postal code: "K1A 0B1" (optional)
|
|
6. User writes comment: "I strongly support this bill because..."
|
|
7. User checks "Email me a copy" checkbox
|
|
8. User clicks "Submit Response"
|
|
9. API creates response with `isVerified=false`
|
|
10. Backend sends verification email to jane@example.com
|
|
11. Success modal displays: "Response submitted! Check your email to verify."
|
|
12. User clicks "OK"
|
|
13. Modal closes
|
|
14. Responses grid refreshes (may not show new response if "Verified Only" filter active)
|
|
|
|
### Upvoting
|
|
|
|
1. User sees response they agree with
|
|
2. User clicks heart icon button
|
|
3. Optimistic update: upvote count increments, heart fills with color
|
|
4. API request to `/api/public/responses/:id/upvote`
|
|
5. If API succeeds: update persists
|
|
6. If API fails: revert to previous state, show error message
|
|
7. User can click again to remove upvote (toggle behavior)
|
|
|
|
---
|
|
|
|
## Component Structure
|
|
|
|
```tsx
|
|
import React, { useState, useEffect } from 'react';
|
|
import { useParams, Link } from 'react-router-dom';
|
|
import {
|
|
Card,
|
|
Row,
|
|
Col,
|
|
Typography,
|
|
Button,
|
|
Select,
|
|
Statistic,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Checkbox,
|
|
Pagination,
|
|
Tag,
|
|
Space,
|
|
message,
|
|
Grid
|
|
} from 'antd';
|
|
import {
|
|
ArrowLeftOutlined,
|
|
CommentOutlined,
|
|
HeartOutlined,
|
|
HeartFilled,
|
|
CheckCircleOutlined,
|
|
TrophyOutlined,
|
|
FireOutlined
|
|
} from '@ant-design/icons';
|
|
import dayjs from 'dayjs';
|
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
|
import PublicLayout from '../../components/PublicLayout';
|
|
import axios from 'axios';
|
|
|
|
dayjs.extend(relativeTime);
|
|
|
|
const { Title, Paragraph, Text } = Typography;
|
|
const { TextArea } = Input;
|
|
const { Option } = Select;
|
|
const { useBreakpoint } = Grid;
|
|
|
|
interface Response {
|
|
id: string;
|
|
userName: string;
|
|
userEmail: string;
|
|
postalCode: string | null;
|
|
comment: string;
|
|
quotedText: string | null;
|
|
isVerified: boolean;
|
|
upvoteCount: number;
|
|
representativeName: string;
|
|
representativeDistrict: string;
|
|
governmentLevel: string;
|
|
createdAt: string;
|
|
hasUpvoted?: boolean; // Client-side tracking
|
|
}
|
|
|
|
interface Campaign {
|
|
id: string;
|
|
title: string;
|
|
}
|
|
|
|
interface Stats {
|
|
totalResponses: number;
|
|
verifiedResponses: number;
|
|
totalUpvotes: number;
|
|
}
|
|
|
|
const ResponseWallPage: React.FC = () => {
|
|
const { campaignId } = useParams<{ campaignId: string }>();
|
|
const [responses, setResponses] = useState<Response[]>([]);
|
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
|
const [stats, setStats] = useState<Stats>({ totalResponses: 0, verifiedResponses: 0, totalUpvotes: 0 });
|
|
const [loading, setLoading] = useState(true);
|
|
const [sortBy, setSortBy] = useState<string>('recent');
|
|
const [governmentLevel, setGovernmentLevel] = useState<string>('all');
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const [submitModalVisible, setSubmitModalVisible] = useState(false);
|
|
const [form] = Form.useForm();
|
|
const screens = useBreakpoint();
|
|
const isMobile = !screens.md;
|
|
|
|
const pageSize = 20;
|
|
|
|
// Data fetching, handlers, etc.
|
|
|
|
return (
|
|
<PublicLayout>
|
|
{/* Back link and title */}
|
|
{/* Statistics cards */}
|
|
{/* Sort and filter controls */}
|
|
{/* Response cards grid */}
|
|
{/* Pagination */}
|
|
{/* Submit modal */}
|
|
</PublicLayout>
|
|
);
|
|
};
|
|
|
|
export default ResponseWallPage;
|
|
```
|
|
|
|
---
|
|
|
|
## State Management
|
|
|
|
### Component State
|
|
|
|
```tsx
|
|
// Response data
|
|
const [responses, setResponses] = useState<Response[]>([]);
|
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
|
const [stats, setStats] = useState<Stats>({
|
|
totalResponses: 0,
|
|
verifiedResponses: 0,
|
|
totalUpvotes: 0
|
|
});
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
// Filtering and sorting
|
|
const [sortBy, setSortBy] = useState<string>('recent'); // 'recent' | 'upvotes' | 'verified'
|
|
const [governmentLevel, setGovernmentLevel] = useState<string>('all'); // 'all' | 'federal' | 'provincial' | 'municipal'
|
|
|
|
// Pagination
|
|
const [page, setPage] = useState(1);
|
|
const [total, setTotal] = useState(0);
|
|
const pageSize = 20;
|
|
|
|
// Modal state
|
|
const [submitModalVisible, setSubmitModalVisible] = useState(false);
|
|
const [form] = Form.useForm();
|
|
|
|
// Responsive
|
|
const screens = useBreakpoint();
|
|
const isMobile = !screens.md;
|
|
```
|
|
|
|
### Derived State
|
|
|
|
```tsx
|
|
// No complex derived state - filtering happens server-side
|
|
// All data transformations done by API
|
|
```
|
|
|
|
### State Flow
|
|
|
|
1. **Initial Load**: `loading=true`, fetch campaign + responses + stats
|
|
2. **Data Received**: `setCampaign()`, `setResponses()`, `setStats()`, `setTotal()`, `loading=false`
|
|
3. **Sort Changed**: `setSortBy()`, `setPage(1)`, refetch responses
|
|
4. **Filter Changed**: `setGovernmentLevel()`, `setPage(1)`, refetch responses
|
|
5. **Page Changed**: `setPage()`, refetch responses (keep sort/filter)
|
|
6. **Upvote Clicked**: Optimistic update to `responses` array, API call
|
|
7. **Submit Clicked**: `setSubmitModalVisible(true)`, open form
|
|
8. **Response Submitted**: API call, `setSubmitModalVisible(false)`, refetch responses
|
|
|
|
---
|
|
|
|
## API Integration
|
|
|
|
### Endpoints Used
|
|
|
|
#### 1. Get Campaign (Basic Info)
|
|
```http
|
|
GET /api/public/campaigns/:campaignId
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"id": "cm1abc123",
|
|
"title": "Support Climate Action Bill"
|
|
}
|
|
```
|
|
|
|
#### 2. Get Response Statistics
|
|
```http
|
|
GET /api/public/responses/campaigns/:campaignId/stats
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"totalResponses": 342,
|
|
"verifiedResponses": 287,
|
|
"totalUpvotes": 1829
|
|
}
|
|
```
|
|
|
|
#### 3. List Responses
|
|
```http
|
|
GET /api/public/responses/campaigns/:campaignId?page=1&limit=20&sortBy=recent&governmentLevel=all
|
|
```
|
|
|
|
**Query Parameters:**
|
|
- `page`: Page number (1-indexed)
|
|
- `limit`: Items per page (default 20, max 100)
|
|
- `sortBy`: `recent` | `upvotes` | `verified`
|
|
- `governmentLevel`: `all` | `federal` | `provincial` | `municipal`
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"responses": [
|
|
{
|
|
"id": "cm2abc123",
|
|
"userName": "Jane Doe",
|
|
"userEmail": "jane@example.com",
|
|
"postalCode": "K1A 0B1",
|
|
"comment": "I strongly support this bill because it addresses critical climate issues...",
|
|
"quotedText": null,
|
|
"isVerified": true,
|
|
"upvoteCount": 47,
|
|
"representativeName": "John Smith",
|
|
"representativeDistrict": "Ottawa Centre",
|
|
"governmentLevel": "federal",
|
|
"createdAt": "2025-02-10T14:30:00.000Z"
|
|
}
|
|
],
|
|
"total": 342,
|
|
"page": 1,
|
|
"limit": 20
|
|
}
|
|
```
|
|
|
|
#### 4. Submit Response
|
|
```http
|
|
POST /api/public/responses
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"campaignId": "cm1abc123",
|
|
"userName": "Jane Doe",
|
|
"userEmail": "jane@example.com",
|
|
"postalCode": "K1A 0B1",
|
|
"comment": "I strongly support this bill...",
|
|
"representativeName": "John Smith",
|
|
"representativeDistrict": "Ottawa Centre",
|
|
"governmentLevel": "federal",
|
|
"sendCopy": true
|
|
}
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"responseId": "cm2def456",
|
|
"message": "Response submitted successfully. Please check your email to verify."
|
|
}
|
|
```
|
|
|
|
#### 5. Upvote Response
|
|
```http
|
|
POST /api/public/responses/:id/upvote
|
|
```
|
|
|
|
**Response:**
|
|
```json
|
|
{
|
|
"success": true,
|
|
"upvoteCount": 48,
|
|
"action": "added"
|
|
}
|
|
```
|
|
|
|
**Note:** Second request to same endpoint toggles (removes upvote), returns `"action": "removed"`.
|
|
|
|
### Request Examples
|
|
|
|
#### Fetch Responses
|
|
```tsx
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
if (!campaignId) return;
|
|
|
|
try {
|
|
setLoading(true);
|
|
|
|
const [campaignRes, statsRes, responsesRes] = await Promise.all([
|
|
axios.get(`/api/public/campaigns/${campaignId}`),
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}`, {
|
|
params: {
|
|
page,
|
|
limit: pageSize,
|
|
sortBy,
|
|
governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel
|
|
}
|
|
})
|
|
]);
|
|
|
|
setCampaign(campaignRes.data);
|
|
setStats(statsRes.data);
|
|
setResponses(responsesRes.data.responses);
|
|
setTotal(responsesRes.data.total);
|
|
|
|
} catch (error) {
|
|
console.error('Failed to fetch data:', error);
|
|
message.error('Failed to load responses');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, [campaignId, page, sortBy, governmentLevel]);
|
|
```
|
|
|
|
#### Submit Response
|
|
```tsx
|
|
const handleSubmit = async (values: any) => {
|
|
try {
|
|
await axios.post('/api/public/responses', {
|
|
campaignId,
|
|
userName: values.userName,
|
|
userEmail: values.userEmail,
|
|
postalCode: values.postalCode || null,
|
|
comment: values.comment,
|
|
representativeName: values.representativeName,
|
|
representativeDistrict: values.representativeDistrict || '',
|
|
governmentLevel: values.governmentLevel || 'federal',
|
|
sendCopy: values.sendCopy
|
|
});
|
|
|
|
Modal.success({
|
|
title: 'Response Submitted!',
|
|
content: 'Please check your email to verify your response.',
|
|
});
|
|
|
|
setSubmitModalVisible(false);
|
|
form.resetFields();
|
|
|
|
// Refresh responses list
|
|
setPage(1);
|
|
// Triggers useEffect refetch
|
|
|
|
} catch (error: any) {
|
|
console.error('Submit failed:', error);
|
|
message.error(error.response?.data?.message || 'Failed to submit response');
|
|
}
|
|
};
|
|
```
|
|
|
|
#### Upvote Response
|
|
```tsx
|
|
const handleUpvote = async (responseId: string) => {
|
|
// Optimistic update
|
|
setResponses(prev => prev.map(r => {
|
|
if (r.id === responseId) {
|
|
const hasUpvoted = !r.hasUpvoted;
|
|
return {
|
|
...r,
|
|
hasUpvoted,
|
|
upvoteCount: r.upvoteCount + (hasUpvoted ? 1 : -1)
|
|
};
|
|
}
|
|
return r;
|
|
}));
|
|
|
|
try {
|
|
const response = await axios.post(`/api/public/responses/${responseId}/upvote`);
|
|
|
|
// Update with server count (in case of race condition)
|
|
setResponses(prev => prev.map(r =>
|
|
r.id === responseId
|
|
? { ...r, upvoteCount: response.data.upvoteCount }
|
|
: r
|
|
));
|
|
|
|
} catch (error) {
|
|
console.error('Upvote failed:', error);
|
|
|
|
// Revert on error
|
|
setResponses(prev => prev.map(r => {
|
|
if (r.id === responseId) {
|
|
return {
|
|
...r,
|
|
hasUpvoted: !r.hasUpvoted,
|
|
upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)
|
|
};
|
|
}
|
|
return r;
|
|
}));
|
|
|
|
message.error('Failed to upvote. Please try again.');
|
|
}
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### Statistics Cards
|
|
|
|
```tsx
|
|
<Row gutter={[16, 16]} style={{ marginBottom: 32 }}>
|
|
<Col xs={24} sm={8}>
|
|
<Card>
|
|
<Statistic
|
|
title="Total Responses"
|
|
value={stats.totalResponses}
|
|
prefix={<CommentOutlined style={{ color: '#1890ff' }} />}
|
|
valueStyle={{ color: '#1890ff', fontSize: 32 }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col xs={24} sm={8}>
|
|
<Card>
|
|
<Statistic
|
|
title="Verified Responses"
|
|
value={stats.verifiedResponses}
|
|
prefix={<CheckCircleOutlined style={{ color: '#52c41a' }} />}
|
|
valueStyle={{ color: '#52c41a', fontSize: 32 }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
|
|
<Col xs={24} sm={8}>
|
|
<Card>
|
|
<Statistic
|
|
title="Total Upvotes"
|
|
value={stats.totalUpvotes}
|
|
prefix={<HeartFilled style={{ color: '#eb2f96' }} />}
|
|
valueStyle={{ color: '#eb2f96', fontSize: 32 }}
|
|
/>
|
|
</Card>
|
|
</Col>
|
|
</Row>
|
|
```
|
|
|
|
### Sort and Filter Controls
|
|
|
|
```tsx
|
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
|
<Col xs={24} sm={12}>
|
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
|
<Text type="secondary">Sort by:</Text>
|
|
<Select
|
|
value={sortBy}
|
|
onChange={(value) => {
|
|
setSortBy(value);
|
|
setPage(1); // Reset to page 1 when sorting changes
|
|
}}
|
|
style={{ width: '100%' }}
|
|
size="large"
|
|
>
|
|
<Option value="recent">
|
|
<FireOutlined /> Recent
|
|
</Option>
|
|
<Option value="upvotes">
|
|
<TrophyOutlined /> Most Upvoted
|
|
</Option>
|
|
<Option value="verified">
|
|
<CheckCircleOutlined /> Verified Only
|
|
</Option>
|
|
</Select>
|
|
</Space>
|
|
</Col>
|
|
|
|
<Col xs={24} sm={12}>
|
|
<Space direction="vertical" style={{ width: '100%' }} size={4}>
|
|
<Text type="secondary">Government Level:</Text>
|
|
<Select
|
|
value={governmentLevel}
|
|
onChange={(value) => {
|
|
setGovernmentLevel(value);
|
|
setPage(1); // Reset to page 1 when filter changes
|
|
}}
|
|
style={{ width: '100%' }}
|
|
size="large"
|
|
>
|
|
<Option value="all">All Levels</Option>
|
|
<Option value="federal">Federal</Option>
|
|
<Option value="provincial">Provincial</Option>
|
|
<Option value="municipal">Municipal</Option>
|
|
</Select>
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
```
|
|
|
|
### Response Cards
|
|
|
|
```tsx
|
|
<Row gutter={[16, 16]}>
|
|
{responses.map((response) => (
|
|
<Col xs={24} key={response.id}>
|
|
<Card hoverable>
|
|
{/* Header */}
|
|
<div style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: 16
|
|
}}>
|
|
<Space>
|
|
<Text strong style={{ fontSize: 16 }}>
|
|
{response.userName}
|
|
</Text>
|
|
{response.isVerified && (
|
|
<Tag color="green" icon={<CheckCircleOutlined />}>
|
|
Verified
|
|
</Tag>
|
|
)}
|
|
</Space>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{dayjs(response.createdAt).fromNow()}
|
|
</Text>
|
|
</div>
|
|
|
|
{/* Comment */}
|
|
<Paragraph style={{ marginBottom: 16, fontSize: 14 }}>
|
|
{response.comment}
|
|
</Paragraph>
|
|
|
|
{/* Quoted Text (if any) */}
|
|
{response.quotedText && (
|
|
<div style={{
|
|
padding: 12,
|
|
background: 'rgba(255,255,255,0.05)',
|
|
borderLeft: '3px solid #1890ff',
|
|
marginBottom: 16,
|
|
fontStyle: 'italic'
|
|
}}>
|
|
<Text type="secondary" style={{ fontSize: 13 }}>
|
|
"{response.quotedText}"
|
|
</Text>
|
|
</div>
|
|
)}
|
|
|
|
{/* Representative Info */}
|
|
<div style={{
|
|
paddingTop: 16,
|
|
borderTop: '1px solid rgba(255,255,255,0.1)',
|
|
marginBottom: 12
|
|
}}>
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
Sent to:{' '}
|
|
</Text>
|
|
<Text strong style={{ fontSize: 13 }}>
|
|
{response.representativeName}
|
|
</Text>
|
|
{response.representativeDistrict && (
|
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
|
{' '}• {response.representativeDistrict}
|
|
</Text>
|
|
)}
|
|
<div style={{ marginTop: 4 }}>
|
|
<Tag color={
|
|
response.governmentLevel === 'federal' ? 'blue' :
|
|
response.governmentLevel === 'provincial' ? 'purple' :
|
|
'green'
|
|
}>
|
|
{response.governmentLevel.charAt(0).toUpperCase() + response.governmentLevel.slice(1)}
|
|
</Tag>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Upvote Button */}
|
|
<Button
|
|
type={response.hasUpvoted ? 'primary' : 'default'}
|
|
icon={response.hasUpvoted ? <HeartFilled /> : <HeartOutlined />}
|
|
onClick={() => handleUpvote(response.id)}
|
|
style={{
|
|
borderColor: '#eb2f96',
|
|
color: response.hasUpvoted ? 'white' : '#eb2f96'
|
|
}}
|
|
>
|
|
{response.upvoteCount} {response.upvoteCount === 1 ? 'Upvote' : 'Upvotes'}
|
|
</Button>
|
|
</Card>
|
|
</Col>
|
|
))}
|
|
</Row>
|
|
```
|
|
|
|
### Submit Response Modal
|
|
|
|
```tsx
|
|
<Modal
|
|
title="Submit Your Response"
|
|
open={submitModalVisible}
|
|
onCancel={() => {
|
|
setSubmitModalVisible(false);
|
|
form.resetFields();
|
|
}}
|
|
footer={null}
|
|
width={600}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={handleSubmit}
|
|
>
|
|
<Form.Item
|
|
name="userName"
|
|
label="Your Name"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your name' },
|
|
{ min: 2, message: 'Name must be at least 2 characters' }
|
|
]}
|
|
>
|
|
<Input size="large" placeholder="Jane Doe" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="userEmail"
|
|
label="Your Email"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your email' },
|
|
{ type: 'email', message: 'Please enter a valid email' }
|
|
]}
|
|
>
|
|
<Input size="large" type="email" placeholder="jane@example.com" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="postalCode"
|
|
label="Your Postal Code (Optional)"
|
|
>
|
|
<Input
|
|
size="large"
|
|
placeholder="K1A 0B1"
|
|
maxLength={7}
|
|
style={{ textTransform: 'uppercase' }}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="representativeName"
|
|
label="Representative You Contacted"
|
|
rules={[{ required: true, message: 'Please enter representative name' }]}
|
|
>
|
|
<Input size="large" placeholder="e.g., John Smith" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="representativeDistrict"
|
|
label="District/Riding (Optional)"
|
|
>
|
|
<Input size="large" placeholder="e.g., Ottawa Centre" />
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="governmentLevel"
|
|
label="Government Level"
|
|
rules={[{ required: true, message: 'Please select government level' }]}
|
|
initialValue="federal"
|
|
>
|
|
<Select size="large">
|
|
<Option value="federal">Federal</Option>
|
|
<Option value="provincial">Provincial/Territorial</Option>
|
|
<Option value="municipal">Municipal</Option>
|
|
</Select>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="comment"
|
|
label="Your Comment"
|
|
rules={[
|
|
{ required: true, message: 'Please enter your comment' },
|
|
{ min: 10, message: 'Comment must be at least 10 characters' },
|
|
{ max: 5000, message: 'Comment must be less than 5000 characters' }
|
|
]}
|
|
>
|
|
<TextArea
|
|
rows={5}
|
|
placeholder="Share your thoughts, the response you received, or why this issue matters to you..."
|
|
showCount
|
|
maxLength={5000}
|
|
/>
|
|
</Form.Item>
|
|
|
|
<Form.Item
|
|
name="sendCopy"
|
|
valuePropName="checked"
|
|
initialValue={true}
|
|
>
|
|
<Checkbox>
|
|
Email me a copy and verification link
|
|
</Checkbox>
|
|
</Form.Item>
|
|
|
|
<Form.Item>
|
|
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
|
|
<Button onClick={() => {
|
|
setSubmitModalVisible(false);
|
|
form.resetFields();
|
|
}}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="primary" htmlType="submit" size="large">
|
|
Submit Response
|
|
</Button>
|
|
</Space>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
```
|
|
|
|
### Pagination
|
|
|
|
```tsx
|
|
{total > pageSize && (
|
|
<div style={{ textAlign: 'center', marginTop: 32 }}>
|
|
<Pagination
|
|
current={page}
|
|
total={total}
|
|
pageSize={pageSize}
|
|
onChange={(newPage) => {
|
|
setPage(newPage);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}}
|
|
showSizeChanger={false}
|
|
showTotal={(total, range) => `${range[0]}-${range[1]} of ${total} responses`}
|
|
/>
|
|
</div>
|
|
)}
|
|
```
|
|
|
|
---
|
|
|
|
## Performance Considerations
|
|
|
|
### 1. Parallel Data Fetching
|
|
|
|
Campaign, stats, and responses fetched simultaneously:
|
|
|
|
```tsx
|
|
const [campaignRes, statsRes, responsesRes] = await Promise.all([
|
|
axios.get(`/api/public/campaigns/${campaignId}`),
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}`, { params })
|
|
]);
|
|
```
|
|
|
|
**Benefit**: Reduces initial load time by ~60% vs sequential requests.
|
|
|
|
### 2. Optimistic Upvote Updates
|
|
|
|
UI updates immediately before API confirmation:
|
|
|
|
```tsx
|
|
// Update UI first
|
|
setResponses(prev => prev.map(r => {
|
|
if (r.id === responseId) {
|
|
return { ...r, hasUpvoted: !r.hasUpvoted, upvoteCount: r.upvoteCount + 1 };
|
|
}
|
|
return r;
|
|
}));
|
|
|
|
// Then API call
|
|
await axios.post(`/api/public/responses/${responseId}/upvote`);
|
|
```
|
|
|
|
**Benefit**: Perceived performance improvement, instant feedback.
|
|
|
|
### 3. Server-Side Filtering
|
|
|
|
All filtering/sorting done via API query params (not client-side):
|
|
|
|
```tsx
|
|
params: {
|
|
sortBy,
|
|
governmentLevel: governmentLevel === 'all' ? undefined : governmentLevel
|
|
}
|
|
```
|
|
|
|
**Benefit**: Scalable to thousands of responses, no client memory issues.
|
|
|
|
### 4. Pagination
|
|
|
|
Limited to 20 responses per page:
|
|
|
|
```tsx
|
|
const pageSize = 20;
|
|
```
|
|
|
|
**Benefit**: Reduces DOM nodes, faster render, better mobile performance.
|
|
|
|
### 5. Scroll to Top on Page Change
|
|
|
|
Smooth scroll when pagination changes:
|
|
|
|
```tsx
|
|
onChange={(newPage) => {
|
|
setPage(newPage);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
}}
|
|
```
|
|
|
|
**Benefit**: Better UX, user doesn't miss new content.
|
|
|
|
---
|
|
|
|
## Responsive Design
|
|
|
|
### Breakpoint Behavior
|
|
|
|
| Breakpoint | Stats Columns | Response Cards | Filter Layout | Modal Width |
|
|
|------------|--------------|----------------|---------------|-------------|
|
|
| xs (0-575px) | 1 column | 1 column | Stacked | 90% viewport |
|
|
| sm (576-767px) | 3 columns | 1 column | Stacked | 90% viewport |
|
|
| md (768-991px) | 3 columns | 1 column | Side-by-side | 600px |
|
|
| lg (992px+) | 3 columns | 1 column | Side-by-side | 600px |
|
|
|
|
### Mobile Adaptations
|
|
|
|
**Statistics Cards:**
|
|
- Stack vertically on xs (easier to scan)
|
|
- Show 3 columns on sm+ (compact display)
|
|
- Font size remains large (32px) for impact
|
|
|
|
**Response Cards:**
|
|
- Always full-width (xs=24)
|
|
- Better readability on narrow screens
|
|
- Upvote button full-width on mobile (future enhancement)
|
|
|
|
**Sort/Filter Controls:**
|
|
- Stack vertically on xs (full-width selects)
|
|
- Side-by-side on sm+ (50% width each)
|
|
- Labels above selects for clarity
|
|
|
|
**Submit Modal:**
|
|
- Width adapts to viewport (90% on mobile, 600px desktop)
|
|
- Form fields always full-width
|
|
- TextArea shrinks to 3 rows on mobile (vs 5 desktop)
|
|
|
|
---
|
|
|
|
## Accessibility
|
|
|
|
### Keyboard Navigation
|
|
|
|
**Response Cards:**
|
|
- Upvote button focusable via Tab
|
|
- Enter/Space toggles upvote
|
|
|
|
**Sort/Filter Controls:**
|
|
- Dropdowns keyboard navigable (Arrow keys + Enter)
|
|
- Focus visible on all select elements
|
|
|
|
**Pagination:**
|
|
- Page numbers focusable
|
|
- Arrow keys navigate pages (native Ant Design)
|
|
|
|
### ARIA Labels
|
|
|
|
**Upvote Button:**
|
|
```tsx
|
|
<Button
|
|
aria-label={`Upvote response by ${response.userName}. Current upvotes: ${response.upvoteCount}`}
|
|
onClick={() => handleUpvote(response.id)}
|
|
>
|
|
{response.upvoteCount} Upvotes
|
|
</Button>
|
|
```
|
|
|
|
**Statistics Cards:**
|
|
```tsx
|
|
<Statistic
|
|
title="Total Responses"
|
|
value={stats.totalResponses}
|
|
aria-label={`Total responses: ${stats.totalResponses}`}
|
|
/>
|
|
```
|
|
|
|
**Modal:**
|
|
```tsx
|
|
<Modal
|
|
title="Submit Your Response"
|
|
aria-labelledby="submit-response-title"
|
|
aria-describedby="submit-response-description"
|
|
>
|
|
```
|
|
|
|
### Screen Reader Support
|
|
|
|
**Verification Badge:**
|
|
```tsx
|
|
<Tag color="green" icon={<CheckCircleOutlined />}>
|
|
<span aria-label="Email verified">Verified</span>
|
|
</Tag>
|
|
```
|
|
|
|
**Timestamp:**
|
|
```tsx
|
|
<Text
|
|
type="secondary"
|
|
aria-label={`Posted ${dayjs(response.createdAt).format('MMMM D, YYYY at h:mm A')}`}
|
|
>
|
|
{dayjs(response.createdAt).fromNow()}
|
|
</Text>
|
|
```
|
|
|
|
**Form Validation:**
|
|
- Error messages announced automatically
|
|
- Required field indicators (`required` attribute)
|
|
- Help text linked via `aria-describedby`
|
|
|
|
---
|
|
|
|
## Troubleshooting
|
|
|
|
### Issue: Upvotes Not Persisting
|
|
|
|
**Symptoms:**
|
|
- User clicks upvote, count increments
|
|
- Page refresh resets upvote
|
|
- Heart icon reverts to outline
|
|
|
|
**Causes:**
|
|
1. API call failing silently
|
|
2. Session/cookie not persisting user ID
|
|
3. Optimistic update not reverting on error
|
|
4. Backend not tracking upvote source
|
|
|
|
**Solutions:**
|
|
|
|
```tsx
|
|
const handleUpvote = async (responseId: string) => {
|
|
// Save previous state for rollback
|
|
const previousResponses = [...responses];
|
|
|
|
// Optimistic update
|
|
setResponses(prev => prev.map(r => {
|
|
if (r.id === responseId) {
|
|
return {
|
|
...r,
|
|
hasUpvoted: !r.hasUpvoted,
|
|
upvoteCount: r.upvoteCount + (r.hasUpvoted ? -1 : 1)
|
|
};
|
|
}
|
|
return r;
|
|
}));
|
|
|
|
try {
|
|
const response = await axios.post(
|
|
`/api/public/responses/${responseId}/upvote`,
|
|
{},
|
|
{ timeout: 5000 }
|
|
);
|
|
|
|
console.log('Upvote response:', response.data);
|
|
|
|
// Update with server count (authoritative)
|
|
setResponses(prev => prev.map(r =>
|
|
r.id === responseId
|
|
? {
|
|
...r,
|
|
upvoteCount: response.data.upvoteCount,
|
|
hasUpvoted: response.data.action === 'added'
|
|
}
|
|
: r
|
|
));
|
|
|
|
} catch (error: any) {
|
|
console.error('Upvote failed:', error);
|
|
|
|
// Revert to previous state
|
|
setResponses(previousResponses);
|
|
|
|
if (error.code === 'ECONNABORTED') {
|
|
message.error('Request timed out. Please try again.');
|
|
} else {
|
|
message.error('Failed to upvote. Please try again.');
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
**Check backend upvote tracking:**
|
|
```sql
|
|
-- Verify upvote records created
|
|
SELECT * FROM "ResponseUpvote"
|
|
WHERE "responseId" = 'cm2abc123'
|
|
ORDER BY "createdAt" DESC;
|
|
```
|
|
|
|
### Issue: Statistics Not Updating After Submission
|
|
|
|
**Symptoms:**
|
|
- User submits response
|
|
- Response appears in list
|
|
- Statistics cards show old counts
|
|
|
|
**Causes:**
|
|
1. Stats fetched once on mount, never refreshed
|
|
2. New response not included in stats query
|
|
3. Cache invalidation not working
|
|
|
|
**Solutions:**
|
|
|
|
```tsx
|
|
// Refetch stats after successful submission
|
|
const handleSubmit = async (values: any) => {
|
|
try {
|
|
await axios.post('/api/public/responses', { ... });
|
|
|
|
Modal.success({
|
|
title: 'Response Submitted!',
|
|
content: 'Please check your email to verify your response.',
|
|
});
|
|
|
|
setSubmitModalVisible(false);
|
|
form.resetFields();
|
|
|
|
// Refresh all data
|
|
const [statsRes, responsesRes] = await Promise.all([
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}/stats`),
|
|
axios.get(`/api/public/responses/campaigns/${campaignId}`, {
|
|
params: { page: 1, limit: pageSize, sortBy, governmentLevel }
|
|
})
|
|
]);
|
|
|
|
setStats(statsRes.data);
|
|
setResponses(responsesRes.data.responses);
|
|
setTotal(responsesRes.data.total);
|
|
setPage(1); // Reset to first page
|
|
|
|
} catch (error: any) {
|
|
message.error(error.response?.data?.message || 'Failed to submit response');
|
|
}
|
|
};
|
|
```
|
|
|
|
### Issue: "Verified Only" Filter Shows No Results
|
|
|
|
**Symptoms:**
|
|
- User selects "Verified Only" sort
|
|
- Grid shows empty state
|
|
- Total count remains high
|
|
|
|
**Causes:**
|
|
1. No verified responses exist yet
|
|
2. API not filtering correctly
|
|
3. Frontend not passing correct param
|
|
|
|
**Solutions:**
|
|
|
|
```tsx
|
|
// Add empty state for no verified responses
|
|
{!loading && responses.length === 0 && sortBy === 'verified' && (
|
|
<Card style={{ textAlign: 'center', padding: 40 }}>
|
|
<CheckCircleOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />
|
|
<Title level={3} type="secondary">
|
|
No Verified Responses Yet
|
|
</Title>
|
|
<Paragraph type="secondary">
|
|
Responses appear here after users verify their email address.
|
|
<br />
|
|
Try selecting "Recent" or "Most Upvoted" to see all responses.
|
|
</Paragraph>
|
|
<Button
|
|
type="primary"
|
|
onClick={() => setSortBy('recent')}
|
|
>
|
|
View All Responses
|
|
</Button>
|
|
</Card>
|
|
)}
|
|
|
|
// Verify API param correctly passed
|
|
useEffect(() => {
|
|
console.log('Fetching with params:', {
|
|
page,
|
|
limit: pageSize,
|
|
sortBy,
|
|
governmentLevel
|
|
});
|
|
}, [page, sortBy, governmentLevel]);
|
|
```
|
|
|
|
**Check backend:**
|
|
```sql
|
|
-- Count verified vs unverified
|
|
SELECT "isVerified", COUNT(*)
|
|
FROM "Response"
|
|
WHERE "campaignId" = 'cm1abc123'
|
|
GROUP BY "isVerified";
|
|
```
|
|
|
|
### Issue: Pagination Showing Wrong Total
|
|
|
|
**Symptoms:**
|
|
- Pagination shows "1-20 of 342"
|
|
- Only 50 total responses exist
|
|
- Total count doesn't match stats card
|
|
|
|
**Causes:**
|
|
1. Stats query counting all campaigns
|
|
2. Responses query filtering by campaign correctly
|
|
3. Stats API endpoint broken
|
|
|
|
**Solutions:**
|
|
|
|
```tsx
|
|
// Use responses total, not stats total, for pagination
|
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
|
|
|
// In fetch responses:
|
|
setResponsesTotal(responsesRes.data.total);
|
|
|
|
// In pagination:
|
|
<Pagination
|
|
current={page}
|
|
total={responsesTotal} // Not stats.totalResponses
|
|
pageSize={pageSize}
|
|
onChange={setPage}
|
|
/>
|
|
|
|
// Add validation
|
|
useEffect(() => {
|
|
if (stats.totalResponses !== responsesTotal) {
|
|
console.warn('Mismatch between stats and pagination totals:', {
|
|
stats: stats.totalResponses,
|
|
pagination: responsesTotal
|
|
});
|
|
}
|
|
}, [stats.totalResponses, responsesTotal]);
|
|
```
|
|
|
|
---
|
|
|
|
## Related Documentation
|
|
|
|
### Public Pages
|
|
- [Campaigns List Page](./campaigns-list-page.md) - Campaign directory
|
|
- [Campaign Detail Page](./campaign-page.md) - Email sending workflow
|
|
- [Map Page](./map-page.md) - Public location mapping
|
|
|
|
### Admin Pages
|
|
- [Response Moderation](../admin/responses-page.md) - Admin moderation tools
|
|
- [Campaigns Management](../admin/campaigns-page.md) - Campaign configuration
|
|
|
|
### Components
|
|
- [PublicLayout](../../components/public-layout.md) - Dark theme wrapper
|
|
- [ShareButtons](../../components/share-buttons.md) - Social sharing
|
|
|
|
### API Documentation
|
|
- [Public Responses API](../../../api/modules/influence/responses-public-routes.md)
|
|
- [Response Upvoting](../../../api/modules/influence/responses-upvote.md)
|
|
|
|
### Architecture
|
|
- [Response Verification System](../../../architecture/response-verification.md)
|
|
- [Email Templates](../../../architecture/email-templates.md)
|