34 KiB
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:
- User clicks "Submit Your Response" button
- Modal opens with empty form
- User fills fields
- Clicks "Submit Response" button
- API creates response (status:
unverified) - Verification email sent if checkbox checked
- Success modal displays
- Form resets
- 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
- User arrives from campaign page via "View Response Wall" link
- Page loads responses (default: recent, all levels)
- User views statistics cards showing community engagement
- User scrolls through response cards
- User reads comments and representative details
- User upvotes responses they agree with
- User clicks pagination to view more responses
Filtering and Sorting
- User selects "Most Upvoted" from sort dropdown
- API re-fetches responses with new sort order
- Grid updates with reordered responses
- User selects "Federal" from government level filter
- API re-fetches with government level filter
- Grid shows only federal responses
- User resets filters to "All Levels" to see everything
Submitting a Response
- User clicks "Submit Your Response" button
- Modal opens with blank form
- User enters name: "Jane Doe"
- User enters email: "jane@example.com"
- User enters postal code: "K1A 0B1" (optional)
- User writes comment: "I strongly support this bill because..."
- User checks "Email me a copy" checkbox
- User clicks "Submit Response"
- API creates response with
isVerified=false - Backend sends verification email to jane@example.com
- Success modal displays: "Response submitted! Check your email to verify."
- User clicks "OK"
- Modal closes
- Responses grid refreshes (may not show new response if "Verified Only" filter active)
Upvoting
- User sees response they agree with
- User clicks heart icon button
- Optimistic update: upvote count increments, heart fills with color
- API request to
/api/public/responses/:id/upvote - If API succeeds: update persists
- If API fails: revert to previous state, show error message
- User can click again to remove upvote (toggle behavior)
Component Structure
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
// 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
// No complex derived state - filtering happens server-side
// All data transformations done by API
State Flow
- Initial Load:
loading=true, fetch campaign + responses + stats - Data Received:
setCampaign(),setResponses(),setStats(),setTotal(),loading=false - Sort Changed:
setSortBy(),setPage(1), refetch responses - Filter Changed:
setGovernmentLevel(),setPage(1), refetch responses - Page Changed:
setPage(), refetch responses (keep sort/filter) - Upvote Clicked: Optimistic update to
responsesarray, API call - Submit Clicked:
setSubmitModalVisible(true), open form - Response Submitted: API call,
setSubmitModalVisible(false), refetch responses
API Integration
Endpoints Used
1. Get Campaign (Basic Info)
GET /api/public/campaigns/:campaignId
Response:
{
"id": "cm1abc123",
"title": "Support Climate Action Bill"
}
2. Get Response Statistics
GET /api/public/responses/campaigns/:campaignId/stats
Response:
{
"totalResponses": 342,
"verifiedResponses": 287,
"totalUpvotes": 1829
}
3. List Responses
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|verifiedgovernmentLevel:all|federal|provincial|municipal
Response:
{
"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
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:
{
"success": true,
"responseId": "cm2def456",
"message": "Response submitted successfully. Please check your email to verify."
}
5. Upvote Response
POST /api/public/responses/:id/upvote
Response:
{
"success": true,
"upvoteCount": 48,
"action": "added"
}
Note: Second request to same endpoint toggles (removes upvote), returns "action": "removed".
Request Examples
Fetch Responses
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
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
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
<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
<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
<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
<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
{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:
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:
// 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):
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:
const pageSize = 20;
Benefit: Reduces DOM nodes, faster render, better mobile performance.
5. Scroll to Top on Page Change
Smooth scroll when pagination changes:
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:
<Button
aria-label={`Upvote response by ${response.userName}. Current upvotes: ${response.upvoteCount}`}
onClick={() => handleUpvote(response.id)}
>
{response.upvoteCount} Upvotes
</Button>
Statistics Cards:
<Statistic
title="Total Responses"
value={stats.totalResponses}
aria-label={`Total responses: ${stats.totalResponses}`}
/>
Modal:
<Modal
title="Submit Your Response"
aria-labelledby="submit-response-title"
aria-describedby="submit-response-description"
>
Screen Reader Support
Verification Badge:
<Tag color="green" icon={<CheckCircleOutlined />}>
<span aria-label="Email verified">Verified</span>
</Tag>
Timestamp:
<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 (
requiredattribute) - 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:
- API call failing silently
- Session/cookie not persisting user ID
- Optimistic update not reverting on error
- Backend not tracking upvote source
Solutions:
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:
-- 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:
- Stats fetched once on mount, never refreshed
- New response not included in stats query
- Cache invalidation not working
Solutions:
// 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:
- No verified responses exist yet
- API not filtering correctly
- Frontend not passing correct param
Solutions:
// 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:
-- 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:
- Stats query counting all campaigns
- Responses query filtering by campaign correctly
- Stats API endpoint broken
Solutions:
// 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 - Campaign directory
- Campaign Detail Page - Email sending workflow
- Map Page - Public location mapping
Admin Pages
- Response Moderation - Admin moderation tools
- Campaigns Management - Campaign configuration
Components
- PublicLayout - Dark theme wrapper
- ShareButtons - Social sharing