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:

  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

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

  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)

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 | 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

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 (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]);

Public Pages

Admin Pages

Components

API Documentation

Architecture