# 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(null); const [loading, setLoading] = useState(true); const [postalCode, setPostalCode] = useState(''); const [representatives, setRepresentatives] = useState([]); const [repsLoading, setRepsLoading] = useState(false); const [userEmail, setUserEmail] = useState(''); const [userName, setUserName] = useState(''); const [customEmailBody, setCustomEmailBody] = useState(''); const [sendingTo, setSendingTo] = useState(null); const screens = useBreakpoint(); const isMobile = !screens.md; // Data fetching, handlers, etc. return ( {/* Hero Section */} {/* Step Indicator */} {/* Step Content */} {/* Share Buttons */} ); }; 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(null); const [loading, setLoading] = useState(true); // Representative lookup const [postalCode, setPostalCode] = useState(''); const [representatives, setRepresentatives] = useState([]); 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(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
{/* Cover Photo or Gradient */}
{campaign.coverPhoto ? ( {campaign.title} ) : (
)} {/* Gradient Overlay */}
{/* Title Overlay */}
{campaign.title}
{/* Statistics Circles */}
{/* Emails Sent Circle */}
{campaign.emailsSentCount} Emails
{/* Responses Circle */}
{campaign.responsesCount} Responses
``` ### Step Indicator ```tsx } /> } disabled={!canProceedToStep2} /> } disabled={!canProceedToStep3} /> ``` ### Representative Cards with Dual Send Options ```tsx {filteredReps.map((rep, idx) => ( {/* Photo */}
{rep.name}
{/* Details */} {rep.name} {rep.elected_office} • {rep.district_name}
{rep.party_name} {rep.government_level.charAt(0).toUpperCase() + rep.government_level.slice(1)}
{/* Contact Info */}
Email:
{rep.email}

{rep.offices?.[0]?.tel && ( <> Phone:
{rep.offices[0].tel}

)} {rep.offices?.[0]?.postal && ( <> Office:
{rep.offices[0].postal} )}
{/* Send Buttons */} {/* SMTP Send (Tracked) */} {/* Mailto (Untracked) */}
))}
``` ### Email Preview with Optional Editing ```tsx You can edit this message ) } > {/* Subject Line */}
Subject:
{campaign.emailSubject}
{/* Email Body */}
Message: {campaign.allowEmailEditing ? (