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¶
- 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¶
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)¶
Response:
2. Get Response Statistics¶
Response:
3. List Responses¶
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:
{
"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¶
Response:
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):
Benefit: Scalable to thousands of responses, no client memory issues.
4. Pagination¶
Limited to 20 responses per page:
Benefit: Reduces DOM nodes, faster render, better mobile performance.
5. Scroll to Top on Page Change¶
Smooth scroll when pagination changes:
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 (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:
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: 1. Stats fetched once on mount, never refreshed 2. New response not included in stats query 3. 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: 1. No verified responses exist yet 2. API not filtering correctly 3. 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: 1. Stats query counting all campaigns 2. Responses query filtering by campaign correctly 3. 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