40 KiB

Campaign Detail Page

Overview

File Path: admin/src/pages/public/CampaignPage.tsx (613 lines)

Route: /campaigns/:id

Role Requirements: Public access (no authentication required)

Purpose: Individual campaign detail page providing a complete advocacy workflow from representative lookup through email sending, with optional response wall integration and social sharing capabilities.

Key Features:

  • 3-step guided process: Info → Reps → Send
  • Step indicator with clickable navigation
  • Hero section with cover photo and real-time statistics
  • Postal code-based representative lookup with government level filtering
  • Dual email sending options: SMTP (tracked) and Email App (mailto)
  • Live email preview with optional editing
  • Response wall integration with CTA button
  • Social sharing buttons
  • Dark blue/teal theme consistent with public pages
  • Mobile-responsive with hamburger navigation

Layout: Uses PublicLayout component with dark theme


Features

1. Step-Based Workflow

Three-step process guides users through advocacy action:

  • Step 1: Campaign Info - Overview, description, statistics
  • Step 2: Your Representatives - Postal code lookup and rep selection
  • Step 3: Send Your Message - Email composition and sending

Step Indicator:

  • Ant Design Steps component
  • Clickable step headers for navigation
  • Current step highlighted in blue
  • Completed steps marked with checkmark
  • Mobile: Switches to vertical orientation

Navigation Controls:

  • "Previous" button (disabled on step 1)
  • "Next" button (changes to "Send Emails" on step 3)
  • "Back to Campaigns" link in header

2. Hero Section

Prominent campaign header with visual branding:

  • Cover Photo: Full-width image (400px desktop, 250px mobile) with gradient overlay
  • Fallback Gradient: Purple-to-blue when no cover photo
  • Title Overlay: Campaign title in white text over semi-transparent background
  • Statistics Circles: Floating overlay with two metrics
    • Emails Sent count (blue circle)
    • Responses count (green circle)
  • Positioning: Absolute positioned in top-right of hero
  • Responsive: Circles stack vertically on mobile

3. Representative Lookup

Government-level aware representative discovery:

  • Postal Code Input: Large text input with search icon
  • Loading State: Spinner in input suffix during lookup
  • Government Level Filtering: Shows only reps matching campaign targets
    • Federal campaigns → Federal MPs only
    • Provincial campaigns → Provincial MPPs/MLAs only
    • Municipal campaigns → Municipal councillors only
    • Multi-level campaigns → All applicable reps
  • Representative Cards: Grid layout with detailed info
    • Circular photo (120px diameter)
    • Name and title
    • District/riding
    • Party badge
    • Email address (copyable)
    • Phone number
    • Office address
    • Send button (primary CTA)
    • Email App button (secondary CTA)
  • Auto-advance: Automatically proceeds to step 3 when reps loaded
  • No Results State: Helpful message suggesting alternate contact methods

4. Email Sending System

Dual-mode email delivery with tracking:

SMTP Send (Tracked):

  • Sends via backend BullMQ queue
  • Tracked in CampaignEmail table
  • Statistics reflected in dashboard
  • Requires valid email address
  • Shows success confirmation
  • Increments "Emails Sent" counter

Email App (Mailto):

  • Opens user's default email client
  • Pre-populates to, subject, body fields
  • Not tracked in system
  • Works offline
  • Better for complex email setups (signatures, attachments)
  • No backend dependency

Email Preview:

  • Live rendering of email template
  • Substitutes {name}, {email}, {postalCode} placeholders
  • Shows subject line
  • Read-only by default
  • Optional editing mode (if allowEmailEditing=true)

5. Response Wall Integration

Campaign-specific response display:

  • "See What Others Are Saying" Button: Links to response wall
  • Response Count Badge: Shows total verified responses
  • Conditional Display: Only shown if responses exist
  • Navigation: Links to /responses/:campaignId

6. Social Sharing

ShareButtons component for campaign promotion:

  • Platforms: X, Facebook, LinkedIn, Reddit, Email, Copy Link
  • Share URL: Current campaign page URL
  • Share Title: Campaign title
  • Share Description: Campaign description (truncated to 200 chars)
  • Positioning: Below main content, above footer

User Workflow

Complete Advocacy Flow

  1. User arrives at campaign page (via /campaigns/:id)
  2. Step 1 loads automatically showing campaign info
  3. User reads description and decides to take action
  4. User clicks "Next" to proceed to Step 2
  5. User enters postal code in "Your Representatives" section
  6. API lookup triggered on blur or Enter key
  7. Representatives filtered by government level
  8. Auto-advance to Step 3 when reps loaded
  9. User reviews email preview with personalized content
  10. User edits email (if allowed by campaign settings)
  11. User clicks "Send" button on rep card (SMTP option)
    • OR clicks "Open in Email App" (mailto option)
  12. Backend creates CampaignEmail record and queues job
  13. Success message displays confirming email sent
  14. User repeats for additional representatives
  15. User views response wall (optional) to see others' activity
  16. User shares campaign on social media

Representative Selection Flow

Representative selection happens implicitly (no checkboxes):

  1. User clicks "Send" on specific rep card
  2. Email sent to that rep only
  3. User can send to multiple reps by clicking multiple cards
  4. Each send creates separate CampaignEmail record
  5. No bulk sending (encourages personalization)

Error Recovery Flow

Invalid Postal Code:

  1. User enters malformed postal code
  2. API returns 404 or empty array
  3. Message displays: "No representatives found"
  4. User corrects postal code
  5. Re-triggers lookup

Email Send Failure:

  1. User clicks Send button
  2. API returns 500 error
  3. Error message displays
  4. Send button remains enabled
  5. User can retry immediately

Missing Information:

  1. User tries to send without entering email
  2. Form validation triggers
  3. Required field highlighted in red
  4. User fills in email
  5. Proceeds with send

Component Structure

import React, { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import {
  Steps,
  Button,
  Input,
  Card,
  Row,
  Col,
  Typography,
  Form,
  message,
  Spin,
  Tag,
  Grid,
  Space
} from 'antd';
import {
  MailOutlined,
  SearchOutlined,
  CommentOutlined,
  ArrowLeftOutlined,
  SendOutlined,
  DesktopOutlined
} from '@ant-design/icons';
import PublicLayout from '../../components/PublicLayout';
import ShareButtons from '../../components/ShareButtons';
import axios from 'axios';

const { Title, Paragraph, Text } = Typography;
const { Step } = Steps;
const { TextArea } = Input;
const { useBreakpoint } = Grid;

interface Campaign {
  id: string;
  title: string;
  description: string | null;
  slug: string;
  coverPhoto: string | null;
  governmentLevel: string[];
  targetType: string;
  emailSubject: string;
  emailBody: string;
  allowEmailEditing: boolean;
  isActive: boolean;
  emailsSentCount: number;
  responsesCount: number;
}

interface Representative {
  name: string;
  district_name: string;
  elected_office: string;
  party_name: string;
  email: string;
  photo_url: string;
  government_level: string;
  offices: Array<{
    tel: string;
    type: string;
    postal: string;
  }>;
}

const CampaignPage: React.FC = () => {
  const { id } = useParams<{ id: string }>();
  const [currentStep, setCurrentStep] = useState(0);
  const [campaign, setCampaign] = useState<Campaign | null>(null);
  const [loading, setLoading] = useState(true);
  const [postalCode, setPostalCode] = useState('');
  const [representatives, setRepresentatives] = useState<Representative[]>([]);
  const [repsLoading, setRepsLoading] = useState(false);
  const [userEmail, setUserEmail] = useState('');
  const [userName, setUserName] = useState('');
  const [customEmailBody, setCustomEmailBody] = useState('');
  const [sendingTo, setSendingTo] = useState<string | null>(null);
  const screens = useBreakpoint();
  const isMobile = !screens.md;

  // Data fetching, handlers, etc.

  return (
    <PublicLayout>
      {/* Hero Section */}
      {/* Step Indicator */}
      {/* Step Content */}
      {/* Share Buttons */}
    </PublicLayout>
  );
};

export default CampaignPage;

State Management

Component State

// Navigation state
const [currentStep, setCurrentStep] = useState(0); // 0=Info, 1=Reps, 2=Send

// Campaign data
const [campaign, setCampaign] = useState<Campaign | null>(null);
const [loading, setLoading] = useState(true);

// Representative lookup
const [postalCode, setPostalCode] = useState('');
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [repsLoading, setRepsLoading] = useState(false);

// User input for email
const [userEmail, setUserEmail] = useState('');
const [userName, setUserName] = useState('');
const [customEmailBody, setCustomEmailBody] = useState('');

// Send state
const [sendingTo, setSendingTo] = useState<string | null>(null); // Rep email being sent to

// Responsive
const screens = useBreakpoint();
const isMobile = !screens.md;

Derived State

// Filtered representatives by government level
const filteredReps = representatives.filter(rep => {
  if (!campaign) return false;
  // Show all reps if campaign targets multiple levels or 'all'
  if (campaign.governmentLevel.includes('all')) return true;
  // Otherwise only show reps matching campaign's government levels
  return campaign.governmentLevel.includes(rep.government_level);
});

// Email preview with substitutions
const emailPreview = useMemo(() => {
  if (!campaign) return '';

  let body = customEmailBody || campaign.emailBody;

  // Replace placeholders
  body = body.replace(/\{name\}/g, userName || '[Your Name]');
  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');

  return body;
}, [campaign, customEmailBody, userName, userEmail, postalCode]);

// Step navigation enabled states
const canProceedToStep2 = !!campaign; // Campaign loaded
const canProceedToStep3 = representatives.length > 0; // Reps found

State Flow

  1. Initial Load: loading=true, fetch campaign by ID
  2. Campaign Loaded: setCampaign(), setLoading(false)
  3. User Enters Postal Code: setPostalCode() updates input
  4. Lookup Triggered: setRepsLoading(true), fetch representatives
  5. Reps Loaded: setRepresentatives(), setRepsLoading(false), auto-advance to step 3
  6. User Customizes Email: setCustomEmailBody() if editing allowed
  7. User Clicks Send: setSendingTo(rep.email), post to API
  8. Send Complete: setSendingTo(null), show success message, increment counter

API Integration

Endpoints Used

1. Get Campaign by ID

GET /api/public/campaigns/:id

Response:

{
  "id": "cm1abc123",
  "title": "Support Climate Action Bill",
  "description": "Urge your representatives to support strong climate legislation...",
  "slug": "climate-action-bill",
  "coverPhoto": "https://example.com/photos/climate.jpg",
  "governmentLevel": ["federal"],
  "targetType": "representatives",
  "emailSubject": "Please Support Bill C-123",
  "emailBody": "Dear {representative},\n\nAs your constituent in {postalCode}, I urge you to support Bill C-123...\n\nSincerely,\n{name}\n{email}",
  "allowEmailEditing": true,
  "isActive": true,
  "emailsSentCount": 1247,
  "responsesCount": 342,
  "createdAt": "2025-01-15T10:00:00.000Z"
}

2. Lookup Representatives

GET /api/public/representatives/lookup?postalCode=K1A0B1

Response:

[
  {
    "name": "John Smith",
    "district_name": "Ottawa Centre",
    "elected_office": "MP",
    "party_name": "Liberal",
    "email": "john.smith@parl.gc.ca",
    "photo_url": "https://represent.opennorth.ca/media/photos/mp-john-smith.jpg",
    "government_level": "federal",
    "offices": [
      {
        "tel": "613-555-1234",
        "type": "constituency",
        "postal": "123 Main St, Ottawa ON K1A 0B1"
      }
    ]
  }
]

3. Send Campaign Email

POST /api/public/campaigns/:id/send-email
Content-Type: application/json

{
  "senderName": "Jane Doe",
  "senderEmail": "jane@example.com",
  "postalCode": "K1A 0B1",
  "recipientName": "John Smith",
  "recipientEmail": "john.smith@parl.gc.ca",
  "customMessage": "Dear MP Smith,\n\nAs your constituent...",
  "government_level": "federal"
}

Response:

{
  "success": true,
  "emailId": "cm2def456",
  "message": "Email queued for sending"
}

Request Examples

Fetch Campaign

useEffect(() => {
  const fetchCampaign = async () => {
    if (!id) {
      message.error('Invalid campaign ID');
      return;
    }

    try {
      setLoading(true);
      const response = await axios.get(`/api/public/campaigns/${id}`);
      setCampaign(response.data);
      setCustomEmailBody(response.data.emailBody); // Initialize with template
    } catch (error: any) {
      console.error('Failed to fetch campaign:', error);
      if (error.response?.status === 404) {
        message.error('Campaign not found');
      } else {
        message.error('Failed to load campaign');
      }
    } finally {
      setLoading(false);
    }
  };

  fetchCampaign();
}, [id]);

Lookup Representatives

const handlePostalCodeLookup = async () => {
  if (!postalCode.trim()) {
    message.warning('Please enter a postal code');
    return;
  }

  try {
    setRepsLoading(true);
    const response = await axios.get('/api/public/representatives/lookup', {
      params: { postalCode: postalCode.trim().toUpperCase() }
    });

    setRepresentatives(response.data);

    if (response.data.length === 0) {
      message.info('No representatives found for this postal code');
    } else {
      // Auto-advance to step 3
      setCurrentStep(2);
      message.success(`Found ${response.data.length} representative(s)`);
    }
  } catch (error) {
    console.error('Lookup failed:', error);
    message.error('Failed to find representatives. Please check the postal code.');
  } finally {
    setRepsLoading(false);
  }
};

Send Email

const handleSendEmail = async (rep: Representative) => {
  if (!userName.trim() || !userEmail.trim()) {
    message.warning('Please enter your name and email');
    return;
  }

  if (!campaign) return;

  try {
    setSendingTo(rep.email);

    await axios.post(`/api/public/campaigns/${campaign.id}/send-email`, {
      senderName: userName,
      senderEmail: userEmail,
      postalCode: postalCode.toUpperCase(),
      recipientName: rep.name,
      recipientEmail: rep.email,
      customMessage: customEmailBody || campaign.emailBody,
      government_level: rep.government_level
    });

    message.success(`Email sent to ${rep.name}!`);

    // Update local counter (optimistic update)
    setCampaign(prev => prev ? {
      ...prev,
      emailsSentCount: prev.emailsSentCount + 1
    } : null);

  } catch (error: any) {
    console.error('Failed to send email:', error);
    message.error(error.response?.data?.message || 'Failed to send email. Please try again.');
  } finally {
    setSendingTo(null);
  }
};

Code Examples

Hero Section with Statistics

<div style={{ position: 'relative', marginBottom: 32 }}>
  {/* Cover Photo or Gradient */}
  <div style={{
    height: isMobile ? 250 : 400,
    overflow: 'hidden',
    position: 'relative',
    borderRadius: 8
  }}>
    {campaign.coverPhoto ? (
      <img
        src={campaign.coverPhoto}
        alt={campaign.title}
        style={{
          width: '100%',
          height: '100%',
          objectFit: 'cover'
        }}
      />
    ) : (
      <div style={{
        width: '100%',
        height: '100%',
        background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
      }} />
    )}

    {/* Gradient Overlay */}
    <div style={{
      position: 'absolute',
      bottom: 0,
      left: 0,
      right: 0,
      height: '50%',
      background: 'linear-gradient(to top, rgba(0,0,0,0.8), transparent)'
    }} />

    {/* Title Overlay */}
    <div style={{
      position: 'absolute',
      bottom: 24,
      left: 24,
      right: isMobile ? 24 : '30%',
      color: 'white'
    }}>
      <Title
        level={1}
        style={{
          color: 'white',
          marginBottom: 8,
          fontSize: isMobile ? 24 : 36
        }}
      >
        {campaign.title}
      </Title>
    </div>

    {/* Statistics Circles */}
    <div style={{
      position: 'absolute',
      top: 24,
      right: 24,
      display: 'flex',
      flexDirection: isMobile ? 'column' : 'row',
      gap: 16
    }}>
      {/* Emails Sent Circle */}
      <div style={{
        background: 'rgba(24, 144, 255, 0.9)',
        borderRadius: '50%',
        width: 100,
        height: 100,
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        color: 'white',
        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
      }}>
        <MailOutlined style={{ fontSize: 24, marginBottom: 4 }} />
        <Text strong style={{ color: 'white', fontSize: 20 }}>
          {campaign.emailsSentCount}
        </Text>
        <Text style={{ color: 'white', fontSize: 12 }}>
          Emails
        </Text>
      </div>

      {/* Responses Circle */}
      <div style={{
        background: 'rgba(82, 196, 26, 0.9)',
        borderRadius: '50%',
        width: 100,
        height: 100,
        display: 'flex',
        flexDirection: 'column',
        alignItems: 'center',
        justifyContent: 'center',
        color: 'white',
        boxShadow: '0 4px 12px rgba(0,0,0,0.3)'
      }}>
        <CommentOutlined style={{ fontSize: 24, marginBottom: 4 }} />
        <Text strong style={{ color: 'white', fontSize: 20 }}>
          {campaign.responsesCount}
        </Text>
        <Text style={{ color: 'white', fontSize: 12 }}>
          Responses
        </Text>
      </div>
    </div>
  </div>
</div>

Step Indicator

<Steps
  current={currentStep}
  onChange={setCurrentStep}
  direction={isMobile ? 'vertical' : 'horizontal'}
  style={{ marginBottom: 32 }}
>
  <Step
    title="Campaign Info"
    description={!isMobile && "Learn about the campaign"}
    icon={<MailOutlined />}
  />
  <Step
    title="Your Representatives"
    description={!isMobile && "Find your elected officials"}
    icon={<SearchOutlined />}
    disabled={!canProceedToStep2}
  />
  <Step
    title="Send Your Message"
    description={!isMobile && "Take action now"}
    icon={<SendOutlined />}
    disabled={!canProceedToStep3}
  />
</Steps>

Representative Cards with Dual Send Options

<Row gutter={[16, 16]}>
  {filteredReps.map((rep, idx) => (
    <Col xs={24} sm={12} lg={8} key={idx}>
      <Card hoverable>
        {/* Photo */}
        <div style={{ textAlign: 'center', marginBottom: 16 }}>
          <img
            src={rep.photo_url || '/default-avatar.png'}
            alt={rep.name}
            style={{
              width: 120,
              height: 120,
              borderRadius: '50%',
              objectFit: 'cover',
              border: '3px solid #1890ff'
            }}
          />
        </div>

        {/* Details */}
        <Title level={4} style={{ marginBottom: 4, textAlign: 'center' }}>
          {rep.name}
        </Title>
        <Text type="secondary" style={{ display: 'block', textAlign: 'center', marginBottom: 8 }}>
          {rep.elected_office}  {rep.district_name}
        </Text>

        <div style={{ textAlign: 'center', marginBottom: 16 }}>
          <Tag color="blue">{rep.party_name}</Tag>
          <Tag color="purple">
            {rep.government_level.charAt(0).toUpperCase() + rep.government_level.slice(1)}
          </Tag>
        </div>

        {/* Contact Info */}
        <div style={{ marginBottom: 16, fontSize: 12 }}>
          <Text strong>Email:</Text>
          <br />
          <Text copyable style={{ fontSize: 12 }}>{rep.email}</Text>
          <br /><br />

          {rep.offices?.[0]?.tel && (
            <>
              <Text strong>Phone:</Text>
              <br />
              <Text style={{ fontSize: 12 }}>{rep.offices[0].tel}</Text>
              <br /><br />
            </>
          )}

          {rep.offices?.[0]?.postal && (
            <>
              <Text strong>Office:</Text>
              <br />
              <Text type="secondary" style={{ fontSize: 12 }}>
                {rep.offices[0].postal}
              </Text>
            </>
          )}
        </div>

        {/* Send Buttons */}
        <Space direction="vertical" style={{ width: '100%' }}>
          {/* SMTP Send (Tracked) */}
          <Button
            type="primary"
            icon={<SendOutlined />}
            block
            loading={sendingTo === rep.email}
            onClick={() => handleSendEmail(rep)}
            disabled={!userName || !userEmail}
          >
            Send Email
          </Button>

          {/* Mailto (Untracked) */}
          <Button
            icon={<DesktopOutlined />}
            block
            onClick={() => {
              const subject = encodeURIComponent(campaign.emailSubject);
              const body = encodeURIComponent(emailPreview);
              window.location.href = `mailto:${rep.email}?subject=${subject}&body=${body}`;
            }}
          >
            Open in Email App
          </Button>
        </Space>
      </Card>
    </Col>
  ))}
</Row>

Email Preview with Optional Editing

<Card
  title="Email Preview"
  style={{ marginBottom: 24 }}
  extra={
    campaign.allowEmailEditing && (
      <Text type="secondary" style={{ fontSize: 12 }}>
        You can edit this message
      </Text>
    )
  }
>
  {/* Subject Line */}
  <div style={{ marginBottom: 16 }}>
    <Text strong>Subject:</Text>
    <br />
    <Text>{campaign.emailSubject}</Text>
  </div>

  {/* Email Body */}
  <div>
    <Text strong>Message:</Text>
    {campaign.allowEmailEditing ? (
      <TextArea
        value={customEmailBody}
        onChange={(e) => setCustomEmailBody(e.target.value)}
        rows={10}
        style={{ marginTop: 8, fontFamily: 'monospace', fontSize: 13 }}
      />
    ) : (
      <pre style={{
        marginTop: 8,
        padding: 16,
        background: '#f5f5f5',
        borderRadius: 4,
        whiteSpace: 'pre-wrap',
        fontFamily: 'inherit',
        fontSize: 13
      }}>
        {emailPreview}
      </pre>
    )}
  </div>

  {/* Placeholder Legend */}
  <div style={{
    marginTop: 16,
    padding: 12,
    background: '#e6f7ff',
    borderRadius: 4,
    fontSize: 12
  }}>
    <Text type="secondary">
      <strong>Available placeholders:</strong> {'{name}'}, {'{email}'}, {'{postalCode}'}
    </Text>
  </div>
</Card>

User Information Form

<Card title="Your Information" style={{ marginBottom: 24 }}>
  <Form layout="vertical">
    <Form.Item
      label="Your Name"
      required
      validateStatus={!userName && 'error'}
      help={!userName && 'Please enter your name'}
    >
      <Input
        size="large"
        placeholder="Jane Doe"
        value={userName}
        onChange={(e) => setUserName(e.target.value)}
      />
    </Form.Item>

    <Form.Item
      label="Your Email"
      required
      validateStatus={!userEmail && 'error'}
      help={!userEmail && 'Please enter your email'}
    >
      <Input
        size="large"
        type="email"
        placeholder="jane@example.com"
        value={userEmail}
        onChange={(e) => setUserEmail(e.target.value)}
      />
    </Form.Item>

    <Form.Item
      label="Postal Code"
      required
      validateStatus={!postalCode && 'error'}
      help={!postalCode && 'Entered in step 2'}
    >
      <Input
        size="large"
        disabled
        value={postalCode}
        style={{ background: '#f5f5f5' }}
      />
    </Form.Item>
  </Form>
</Card>

Response Wall CTA

{campaign.responsesCount > 0 && (
  <Card
    style={{
      marginTop: 32,
      background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
      border: 'none'
    }}
  >
    <div style={{ textAlign: 'center', color: 'white' }}>
      <CommentOutlined style={{ fontSize: 48, marginBottom: 16 }} />
      <Title level={3} style={{ color: 'white', marginBottom: 16 }}>
        See What Others Are Saying
      </Title>
      <Paragraph style={{ color: 'rgba(255,255,255,0.9)', marginBottom: 24 }}>
        Read {campaign.responsesCount} responses from people who took action
      </Paragraph>
      <Link to={`/responses/${campaign.id}`}>
        <Button type="default" size="large">
          View Response Wall
        </Button>
      </Link>
    </div>
  </Card>
)}

Navigation Controls

<div style={{
  display: 'flex',
  justifyContent: 'space-between',
  marginTop: 32,
  paddingTop: 24,
  borderTop: '1px solid #303030'
}}>
  <Button
    onClick={() => setCurrentStep(prev => Math.max(0, prev - 1))}
    disabled={currentStep === 0}
  >
    <ArrowLeftOutlined /> Previous
  </Button>

  {currentStep < 2 ? (
    <Button
      type="primary"
      onClick={() => setCurrentStep(prev => Math.min(2, prev + 1))}
      disabled={
        (currentStep === 0 && !campaign) ||
        (currentStep === 1 && representatives.length === 0)
      }
    >
      Next <ArrowLeftOutlined style={{ transform: 'rotate(180deg)' }} />
    </Button>
  ) : (
    <Text type="secondary">
      Click "Send Email" on any representative card above
    </Text>
  )}
</div>

Performance Considerations

1. Optimized Email Preview Rendering

Uses useMemo to avoid re-computing on every render:

const emailPreview = useMemo(() => {
  if (!campaign) return '';

  let body = customEmailBody || campaign.emailBody;

  body = body.replace(/\{name\}/g, userName || '[Your Name]');
  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');

  return body;
}, [campaign, customEmailBody, userName, userEmail, postalCode]);

Benefit: Preview only recalculates when dependencies change, not on every keystroke.

2. Auto-advance After Lookup

Automatically proceeds to step 3 when representatives loaded:

if (response.data.length > 0) {
  setCurrentStep(2); // Auto-advance
  message.success(`Found ${response.data.length} representative(s)`);
}

Benefit: Reduces user clicks, smoother workflow.

3. Optimistic UI Updates

Updates email counter immediately after send (before API response):

message.success(`Email sent to ${rep.name}!`);

setCampaign(prev => prev ? {
  ...prev,
  emailsSentCount: prev.emailsSentCount + 1
} : null);

Benefit: Instant feedback, perceived performance improvement.

4. Conditional Component Rendering

Response wall CTA only renders if responses exist:

{campaign.responsesCount > 0 && (
  <Card>{/* Response wall CTA */}</Card>
)}

Benefit: Cleaner DOM, faster initial render for new campaigns.

5. Debounced Representative Filtering

Filtering happens on blur/Enter, not on every keystroke:

<Input
  onBlur={handlePostalCodeLookup}
  onPressEnter={handlePostalCodeLookup}
  // NOT: onChange={handlePostalCodeLookup}
/>

Benefit: Prevents excessive API calls while user types.


Responsive Design

Breakpoint Behavior

Breakpoint Hero Height Stats Position Steps Direction Rep Cards Columns
xs (0-575px) 250px Vertical stack Vertical 1
sm (576-767px) 250px Vertical stack Vertical 2
md (768-991px) 400px Horizontal row Horizontal 2
lg (992px+) 400px Horizontal row Horizontal 3

Mobile Adaptations

Hero Section:

  • Reduced height (250px vs 400px)
  • Statistics circles stack vertically
  • Title font size reduced (24px vs 36px)
  • Right margin for title increased to prevent overlap with stats

Steps Component:

  • Switches to vertical orientation
  • Step descriptions hidden on mobile (takes too much space)
  • Icons remain visible for visual guidance

Representative Cards:

  • Single column layout on xs
  • Two columns on sm (tablet portrait)
  • Three columns on lg+ (desktop)

Form Inputs:

  • Full-width inputs on mobile
  • size="large" for better touch targets
  • Increased spacing between fields

Email Preview:

  • TextArea expands to full width
  • Font size slightly smaller (13px) for better fit
  • Scrollable if content exceeds viewport

Tablet Optimization

At sm breakpoint (576-767px):

  • Rep cards show 2 per row (good balance)
  • Hero maintains mobile height (better above-fold)
  • Steps remain vertical (clearer on narrow viewports)
  • Send buttons remain full-width within cards

Accessibility

Keyboard Navigation

Step Navigation:

  • Steps component is keyboard accessible (Tab + Enter)
  • Arrow keys navigate between steps (native Ant Design)
  • Space bar activates step

Form Fields:

  • All inputs focusable via Tab
  • Enter key submits postal code lookup
  • Escape key can close modals (future feature)

Send Buttons:

  • Both "Send Email" and "Open in Email App" are focusable
  • Enter/Space activates button
  • Loading state prevents double-submission

ARIA Labels

Step Indicator:

<Steps
  current={currentStep}
  aria-label="Campaign action steps"
>
  <Step
    title="Campaign Info"
    icon={<MailOutlined aria-hidden="true" />}
  />
</Steps>

Representative Photos:

<img
  src={rep.photo_url}
  alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}
  role="img"
/>

Loading States:

<Spin
  size="small"
  aria-label="Loading representatives"
/>

<Button
  loading={sendingTo === rep.email}
  aria-label={`Sending email to ${rep.name}`}
>
  Send Email
</Button>

Screen Reader Support

Step Announcements:

  • Current step announced when changed
  • Step titles are clear and descriptive
  • Disabled steps have appropriate aria-disabled attribute

Form Validation:

<Form.Item
  label="Your Name"
  required
  validateStatus={!userName && 'error'}
  help={!userName && 'Please enter your name'}
  aria-required="true"
>
  <Input />
</Form.Item>

Success/Error Messages:

  • Ant Design message component has ARIA live region
  • Screen reader announces "Email sent successfully!"
  • Error messages also announced automatically

Email Preview:

<pre
  role="article"
  aria-label="Email message preview"
>
  {emailPreview}
</pre>

Color Contrast

Statistics Circles:

  • Blue circle: #1890ff on white text (4.5:1 ratio ✓)
  • Green circle: #52c41a on white text (4.7:1 ratio ✓)
  • Both meet WCAG AA standards

Primary Buttons:

  • Ant Design primary button (#1890ff) meets AA contrast
  • Focus outline visible on all interactive elements

Text Hierarchy:

  • Primary text: white on #0d1b2a (15.8:1 ratio ✓✓)
  • Secondary text: rgba(255,255,255,0.65) on dark (7.2:1 ratio ✓)
  • Links: #1890ff with underline on focus

Troubleshooting

Issue: Representatives Not Filtered by Government Level

Symptoms:

  • Federal campaign shows provincial/municipal reps
  • All reps display regardless of campaign targets
  • Filtering logic not working

Causes:

  1. government_level field missing in API response
  2. governmentLevel array empty in campaign
  3. Case mismatch (Federal vs federal)
  4. Filtering logic bug

Solutions:

// Add debug logging
useEffect(() => {
  if (representatives.length > 0 && campaign) {
    console.log('Campaign levels:', campaign.governmentLevel);
    console.log('Rep levels:', representatives.map(r => r.government_level));
    console.log('Filtered count:', filteredReps.length);
  }
}, [representatives, campaign]);

// Robust filtering with case-insensitive matching
const filteredReps = representatives.filter(rep => {
  if (!campaign || !rep.government_level) return false;

  // Normalize to lowercase for comparison
  const campaignLevels = campaign.governmentLevel.map(l => l.toLowerCase());
  const repLevel = rep.government_level.toLowerCase();

  // Show all if campaign targets 'all' levels
  if (campaignLevels.includes('all')) return true;

  // Otherwise match exact level
  return campaignLevels.includes(repLevel);
});

// Add fallback if no filtered reps
{filteredReps.length === 0 && representatives.length > 0 && (
  <Alert
    type="warning"
    message="No matching representatives"
    description={`This campaign targets ${campaign.governmentLevel.join(', ')} representatives, but none were found for your postal code at that level.`}
    style={{ marginBottom: 16 }}
  />
)}

Check API response:

# Verify government_level field present
curl http://localhost:4000/api/public/representatives/lookup?postalCode=K1A0B1 | jq '.[].government_level'
# Should output: "federal", "provincial", etc.

Issue: Email Preview Not Updating

Symptoms:

  • Placeholders remain as {name} instead of actual values
  • User input not reflected in preview
  • Preview frozen on initial template

Causes:

  1. useMemo dependencies missing
  2. State not updating properly
  3. Placeholder regex not matching
  4. Component not re-rendering

Solutions:

// Ensure all dependencies in useMemo
const emailPreview = useMemo(() => {
  if (!campaign) return '';

  let body = customEmailBody || campaign.emailBody;

  // Use global replace with /g flag
  body = body.replace(/\{name\}/g, userName || '[Your Name]');
  body = body.replace(/\{email\}/g, userEmail || '[Your Email]');
  body = body.replace(/\{postalCode\}/g, postalCode || '[Your Postal Code]');

  // Log for debugging
  console.log('Preview updated:', {
    userName,
    userEmail,
    postalCode,
    bodyLength: body.length
  });

  return body;
}, [campaign, customEmailBody, userName, userEmail, postalCode]);
// ^^^ All dependencies must be listed

// Alternative: Force re-render with key
<pre key={`${userName}-${userEmail}-${postalCode}`}>
  {emailPreview}
</pre>

Issue: Send Button Not Working

Symptoms:

  • Clicking "Send Email" does nothing
  • No API request in Network tab
  • Button not disabled/loading

Causes:

  1. Missing form validation
  2. Event handler not bound
  3. API endpoint incorrect
  4. CORS error blocking request

Solutions:

// Add comprehensive validation
const handleSendEmail = async (rep: Representative) => {
  // Validate user input
  if (!userName.trim()) {
    message.error('Please enter your name');
    return;
  }

  if (!userEmail.trim()) {
    message.error('Please enter your email');
    return;
  }

  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(userEmail)) {
    message.error('Please enter a valid email address');
    return;
  }

  if (!postalCode.trim()) {
    message.error('Postal code is required (from step 2)');
    return;
  }

  if (!campaign) {
    message.error('Campaign data not loaded');
    return;
  }

  // Log request details
  console.log('Sending email:', {
    campaignId: campaign.id,
    to: rep.email,
    from: userEmail
  });

  try {
    setSendingTo(rep.email);

    const payload = {
      senderName: userName.trim(),
      senderEmail: userEmail.trim(),
      postalCode: postalCode.trim().toUpperCase(),
      recipientName: rep.name,
      recipientEmail: rep.email,
      customMessage: customEmailBody || campaign.emailBody,
      government_level: rep.government_level
    };

    console.log('Payload:', payload);

    const response = await axios.post(
      `/api/public/campaigns/${campaign.id}/send-email`,
      payload,
      { timeout: 10000 } // 10s timeout
    );

    console.log('Response:', response.data);

    message.success(`Email sent to ${rep.name}!`);

    // Optimistic update
    setCampaign(prev => prev ? {
      ...prev,
      emailsSentCount: prev.emailsSentCount + 1
    } : null);

  } catch (error: any) {
    console.error('Send error:', error);

    if (error.code === 'ECONNABORTED') {
      message.error('Request timed out. Please try again.');
    } else if (error.response) {
      message.error(error.response.data?.message || 'Failed to send email');
    } else {
      message.error('Network error. Please check your connection.');
    }
  } finally {
    setSendingTo(null);
  }
};

Check CORS configuration:

// In api/src/server.ts
app.use(cors({
  origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
  credentials: true
}));

Issue: Auto-advance to Step 3 Not Working

Symptoms:

  • Representatives load but page stays on step 2
  • User must manually click "Next"
  • Auto-advance logic not triggering

Causes:

  1. State update timing issue
  2. Conditional check failing
  3. React Strict Mode double-rendering
  4. Missing setCurrentStep(2) call

Solutions:

// Move auto-advance inside success branch
const handlePostalCodeLookup = async () => {
  if (!postalCode.trim()) {
    message.warning('Please enter a postal code');
    return;
  }

  try {
    setRepsLoading(true);
    const response = await axios.get('/api/public/representatives/lookup', {
      params: { postalCode: postalCode.trim().toUpperCase() }
    });

    setRepresentatives(response.data);

    // Auto-advance ONLY if reps found
    if (response.data.length > 0) {
      // Use setTimeout to ensure state update completes
      setTimeout(() => {
        setCurrentStep(2);
        message.success(`Found ${response.data.length} representative(s)`);
      }, 100);
    } else {
      message.info('No representatives found for this postal code');
    }
  } catch (error) {
    console.error('Lookup failed:', error);
    message.error('Failed to find representatives');
  } finally {
    setRepsLoading(false);
  }
};

// Alternative: Use useEffect to watch for reps
useEffect(() => {
  if (representatives.length > 0 && currentStep === 1) {
    setCurrentStep(2);
  }
}, [representatives.length, currentStep]);

Symptoms:

  • Clicking "Open in Email App" does nothing
  • Browser blocks mailto: protocol
  • Email client doesn't open

Causes:

  1. Browser security settings blocking mailto
  2. No default email client configured
  3. URL encoding issues
  4. Email body too long (URL length limit)

Solutions:

// Add error handling for mailto
const handleMailtoClick = (rep: Representative) => {
  try {
    const subject = encodeURIComponent(campaign.emailSubject);
    const body = encodeURIComponent(emailPreview);

    // Check URL length (browsers have ~2000 char limit)
    const mailtoUrl = `mailto:${rep.email}?subject=${subject}&body=${body}`;

    if (mailtoUrl.length > 2000) {
      message.warning(
        'Email message is too long for mailto link. ' +
        'Please use the "Send Email" button instead.',
        5
      );
      return;
    }

    // Try to open mailto
    window.location.href = mailtoUrl;

    // Show informative message
    message.info(
      'Opening your email client. If nothing happens, please check your browser settings.',
      5
    );

  } catch (error) {
    console.error('Mailto error:', error);
    message.error('Failed to open email client. Please use the "Send Email" button instead.');
  }
};

// Update button
<Button
  icon={<DesktopOutlined />}
  block
  onClick={() => handleMailtoClick(rep)}
>
  Open in Email App
</Button>

Public Pages

Admin Pages

Components

API Documentation

Architecture