1571 lines
40 KiB
Markdown
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)
|