1571 lines
40 KiB
Markdown

# 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
```tsx
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
```tsx
// 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
```tsx
// 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
```http
GET /api/public/campaigns/:id
```
**Response:**
```json
{
"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
```http
GET /api/public/representatives/lookup?postalCode=K1A0B1
```
**Response:**
```json
[
{
"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
```http
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:**
```json
{
"success": true,
"emailId": "cm2def456",
"message": "Email queued for sending"
}
```
### Request Examples
#### Fetch Campaign
```tsx
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
```tsx
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
```tsx
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
```tsx
<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
```tsx
<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
```tsx
<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
```tsx
<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
```tsx
<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
```tsx
{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
```tsx
<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:
```tsx
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:
```tsx
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):
```tsx
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:
```tsx
{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:
```tsx
<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:**
```tsx
<Steps
current={currentStep}
aria-label="Campaign action steps"
>
<Step
title="Campaign Info"
icon={<MailOutlined aria-hidden="true" />}
/>
</Steps>
```
**Representative Photos:**
```tsx
<img
src={rep.photo_url}
alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}
role="img"
/>
```
**Loading States:**
```tsx
<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:**
```tsx
<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:**
```tsx
<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:**
```tsx
// 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:**
```bash
# 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:**
```tsx
// 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:**
```tsx
// 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:**
```typescript
// 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:**
```tsx
// 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:**
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:**
```tsx
// 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](./campaigns-list-page.md) - Campaign directory and featured campaigns
- [Response Wall Page](./response-wall-page.md) - Campaign-specific response display
- [Map Page](./map-page.md) - Public location mapping
### Admin Pages
- [Campaigns Management](../admin/campaigns-page.md) - Campaign CRUD and settings
- [Email Queue Page](../admin/email-queue-page.md) - Queue monitoring and management
- [Response Moderation](../admin/responses-page.md) - Admin response management
### Components
- [PublicLayout](../../components/public-layout.md) - Dark theme layout wrapper
- [ShareButtons](../../components/share-buttons.md) - Social sharing functionality
### API Documentation
- [Public Campaigns API](../../../api/modules/influence/campaigns-public-routes.md)
- [Campaign Email Sending](../../../api/modules/influence/campaign-emails-routes.md)
- [Representatives Lookup](../../../api/modules/influence/representatives-routes.md)
### Architecture
- [Email Queue System](../../../architecture/email-queue.md) - BullMQ email processing
- [Representative Caching](../../../architecture/representative-cache.md)
- [Postal Code Lookup](../../../architecture/postal-code-service.md)