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
CampaignEmailtable - 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
- User arrives at campaign page (via
/campaigns/:id) - Step 1 loads automatically showing campaign info
- User reads description and decides to take action
- User clicks "Next" to proceed to Step 2
- User enters postal code in "Your Representatives" section
- API lookup triggered on blur or Enter key
- Representatives filtered by government level
- Auto-advance to Step 3 when reps loaded
- User reviews email preview with personalized content
- User edits email (if allowed by campaign settings)
- User clicks "Send" button on rep card (SMTP option)
- OR clicks "Open in Email App" (mailto option)
- Backend creates CampaignEmail record and queues job
- Success message displays confirming email sent
- User repeats for additional representatives
- User views response wall (optional) to see others' activity
- User shares campaign on social media
Representative Selection Flow
Representative selection happens implicitly (no checkboxes):
- User clicks "Send" on specific rep card
- Email sent to that rep only
- User can send to multiple reps by clicking multiple cards
- Each send creates separate CampaignEmail record
- No bulk sending (encourages personalization)
Error Recovery Flow
Invalid Postal Code:
- User enters malformed postal code
- API returns 404 or empty array
- Message displays: "No representatives found"
- User corrects postal code
- Re-triggers lookup
Email Send Failure:
- User clicks Send button
- API returns 500 error
- Error message displays
- Send button remains enabled
- User can retry immediately
Missing Information:
- User tries to send without entering email
- Form validation triggers
- Required field highlighted in red
- User fills in email
- 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
- Initial Load:
loading=true, fetch campaign by ID - Campaign Loaded:
setCampaign(),setLoading(false) - User Enters Postal Code:
setPostalCode()updates input - Lookup Triggered:
setRepsLoading(true), fetch representatives - Reps Loaded:
setRepresentatives(),setRepsLoading(false), auto-advance to step 3 - User Customizes Email:
setCustomEmailBody()if editing allowed - User Clicks Send:
setSendingTo(rep.email), post to API - 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:
government_levelfield missing in API responsegovernmentLevelarray empty in campaign- Case mismatch (Federal vs federal)
- 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:
useMemodependencies missing- State not updating properly
- Placeholder regex not matching
- 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:
- Missing form validation
- Event handler not bound
- API endpoint incorrect
- 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:
- State update timing issue
- Conditional check failing
- React Strict Mode double-rendering
- 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]);
Issue: Mailto Links Not Working
Symptoms:
- Clicking "Open in Email App" does nothing
- Browser blocks mailto: protocol
- Email client doesn't open
Causes:
- Browser security settings blocking mailto
- No default email client configured
- URL encoding issues
- 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>
Related Documentation
Public Pages
- Campaigns List Page - Campaign directory and featured campaigns
- Response Wall Page - Campaign-specific response display
- Map Page - Public location mapping
Admin Pages
- Campaigns Management - Campaign CRUD and settings
- Email Queue Page - Queue monitoring and management
- Response Moderation - Admin response management
Components
- PublicLayout - Dark theme layout wrapper
- ShareButtons - Social sharing functionality
API Documentation
Architecture
- Email Queue System - BullMQ email processing
- Representative Caching
- Postal Code Lookup