# Campaigns List Page ## Overview **File Path:** `admin/src/pages/public/CampaignsListPage.tsx` (566 lines) **Route:** `/campaigns` **Role Requirements:** Public access (no authentication required) **Purpose:** Primary landing page for the advocacy campaign system, providing a browseable directory of active campaigns with featured campaign highlighting, postal code-based representative lookup, and social sharing capabilities. **Key Features:** - Hero banner with organization name and gradient background - "Find Your Representatives" postal code lookup section - Featured campaign card with gold border and star icon - Responsive campaigns grid (3 columns on desktop) - Individual campaign cards with cover photos or gradient backgrounds - ShareButtons component for social media sharing - Dark blue/teal theme consistent with public pages - Real-time campaign statistics (emails sent, responses) - Mobile-responsive design with hamburger navigation **Layout:** Uses `PublicLayout` component with dark theme (`colorBgBase: '#0d1b2a'`, `colorBgContainer: '#1b2838'`) --- ## Features ### 1. Hero Banner The hero section provides visual branding and context: - **Organization Name Display**: Fetched from site settings API - **Gradient Background**: `linear-gradient(135deg, #667eea 0%, #764ba2 100%)` - **Typography**: Large heading (32px desktop, 24px mobile) - **Tagline**: "Join thousands taking action" with email icon - **Height**: 250px on desktop, 200px on mobile ### 2. Find Your Representatives Section Postal code lookup interface for representative discovery: - **Input Field**: Text input with search icon prefix - **Loading States**: Spinning icon during API lookup - **Representative Cards**: Grid display (xs=1, sm=2, lg=3 columns) - **Card Details**: - Representative photo (150x150 circular avatar) - Name with title formatting - District/riding information - Political party with badge styling - Contact information (email, phone) - Office address - **No Results State**: Informative message with alternate contact suggestion - **Government Level Filtering**: Shows reps from all applicable levels ### 3. Featured Campaign Card Highlighted campaign with premium styling: - **Gold Border**: `2px solid #f39c12` with glow shadow - **Star Icon**: Antd StarFilled in gold color - **"Featured Campaign" Badge**: Gold text on dark background - **Cover Photo**: Full-width image (300px height) with overlay gradient - **Fallback Gradient**: Purple-to-blue gradient when no cover photo - **Statistics Display**: Emails sent and responses count - **Action Button**: Primary styled "View Campaign" link - **Positioning**: Always appears first in grid ### 4. Campaigns Grid Responsive grid layout for all campaigns: - **Responsive Columns**: - xs: 1 column (mobile) - sm: 2 columns (tablet) - lg: 3 columns (desktop) - **Gutter**: 24px horizontal and vertical spacing - **Card Components**: Ant Design Card with hover effects - **Card Contents**: - Cover photo or gradient background (200px height) - Campaign title (Typography.Title level 4) - Truncated description (2-line ellipsis) - Government level tags (federal, provincial, municipal) - Statistics row (emails sent, responses) - "View Campaign" link button ### 5. Social Sharing ShareButtons component integration: - **Platforms**: X (Twitter), Facebook, LinkedIn, Reddit, Email, Copy Link - **URL Sharing**: Current page URL - **Title Sharing**: "Check out these advocacy campaigns!" - **Positioning**: Below campaigns grid - **Icon Buttons**: Circular buttons with platform-specific colors - **Copy Link Feedback**: Success message notification ### 6. Empty States Graceful handling of no-data scenarios: - **No Campaigns**: Large icon with "No campaigns available" message - **No Featured Campaign**: Skips featured section, shows all campaigns equally - **Loading State**: Ant Design Spin component with centered alignment --- ## User Workflow ### Initial Page Load 1. User navigates to `/campaigns` 2. PublicLayout renders with dark theme 3. Component fetches settings from `/api/settings` 4. Component fetches campaigns from `/api/public/campaigns` 5. Hero banner displays organization name 6. Campaigns grid renders with featured campaign (if exists) highlighted 7. ShareButtons component appears at bottom ### Representative Lookup Flow 1. User enters postal code in "Find Your Representatives" input 2. On blur or Enter key, component triggers lookup 3. Loading spinner appears in input suffix 4. API request to `/api/public/representatives/lookup?postalCode=X` 5. Results display in grid format with rep cards 6. User can view contact details for each representative 7. Empty state message if no results found ### Campaign Browsing 1. User scrolls through campaigns grid 2. Featured campaign (if exists) appears first with gold border 3. User clicks "View Campaign" on any card 4. Navigation to `/campaigns/:id` detail page 5. Statistics update dynamically based on campaign activity ### Social Sharing 1. User scrolls to bottom of page 2. User clicks desired social platform icon 3. Platform-specific share dialog opens (new window) 4. For "Copy Link", URL copied to clipboard with notification 5. User can share to multiple platforms sequentially --- ## Component Structure ```tsx import React, { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { Row, Col, Card, Typography, Input, Spin, message, Tag, Grid } from 'antd'; import { MailOutlined, SearchOutlined, CommentOutlined, StarFilled, InboxOutlined } from '@ant-design/icons'; import PublicLayout from '../../components/PublicLayout'; import ShareButtons from '../../components/ShareButtons'; import axios from 'axios'; const { Title, Paragraph, Text } = Typography; const { useBreakpoint } = Grid; interface Campaign { id: string; title: string; description: string | null; slug: string; coverPhoto: string | null; governmentLevel: string[]; targetType: string; isFeatured: 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; offices: Array<{ tel: string; type: string; postal: string; }>; } interface Settings { organizationName: string; } const CampaignsListPage: React.FC = () => { const [campaigns, setCampaigns] = useState([]); const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [postalCode, setPostalCode] = useState(''); const [representatives, setRepresentatives] = useState([]); const [repsLoading, setRepsLoading] = useState(false); const screens = useBreakpoint(); const isMobile = !screens.md; // Data fetching, event handlers, etc. return ( {/* Hero Banner */}
{/* Content */}
{/* Find Your Representatives */}
{/* Postal code input and results */}
{/* Campaigns Grid */}
{/* Featured campaign */} {/* Regular campaigns */}
{/* Social Sharing */}
); }; export default CampaignsListPage; ``` --- ## State Management ### Component State ```tsx // Campaign data state const [campaigns, setCampaigns] = useState([]); const [loading, setLoading] = useState(true); // Settings state const [settings, setSettings] = useState(null); // Representative lookup state const [postalCode, setPostalCode] = useState(''); const [representatives, setRepresentatives] = useState([]); const [repsLoading, setRepsLoading] = useState(false); // Responsive design state const screens = useBreakpoint(); const isMobile = !screens.md; ``` ### Derived State ```tsx // Separate featured and regular campaigns const featuredCampaign = campaigns.find(c => c.isFeatured); const regularCampaigns = campaigns.filter(c => !c.isFeatured); // Filter active campaigns only (done server-side in API) // API returns only isActive=true campaigns ``` ### State Flow 1. **Initial Load**: `loading=true`, fetch campaigns and settings in parallel 2. **Data Received**: `setCampaigns()`, `setSettings()`, `setLoading(false)` 3. **Postal Code Entry**: User types, `setPostalCode()` updates state 4. **Lookup Trigger**: On blur/Enter, `setRepsLoading(true)`, fetch reps 5. **Reps Received**: `setRepresentatives()`, `setRepsLoading(false)` 6. **Error Handling**: Display message.error(), reset loading states --- ## API Integration ### Endpoints Used #### 1. Get Settings ```http GET /api/settings ``` **Response:** ```json { "organizationName": "Progressive Action Network", "contactEmail": "contact@example.org", "allowPublicRegistration": true, "defaultMapCenter": [45.5017, -73.5673], "defaultMapZoom": 12 } ``` #### 2. List Public Campaigns ```http GET /api/public/campaigns ``` **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", "isFeatured": true, "isActive": true, "emailsSentCount": 1247, "responsesCount": 342, "createdAt": "2025-01-15T10:00:00.000Z", "updatedAt": "2025-02-10T14:30:00.000Z" } ] ``` #### 3. 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", "offices": [ { "tel": "613-555-1234", "type": "constituency", "postal": "123 Main St, Ottawa ON K1A 0B1" } ], "government_level": "federal" } ] ``` ### Request Examples #### Fetch Campaigns ```tsx useEffect(() => { const fetchData = async () => { try { setLoading(true); const [campaignsRes, settingsRes] = await Promise.all([ axios.get('/api/public/campaigns'), axios.get('/api/settings') ]); setCampaigns(campaignsRes.data); setSettings(settingsRes.data); } catch (error) { console.error('Failed to fetch data:', error); message.error('Failed to load campaigns'); } finally { setLoading(false); } }; fetchData(); }, []); ``` #### 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'); } } catch (error) { console.error('Lookup failed:', error); message.error('Failed to find representatives. Please check the postal code.'); } finally { setRepsLoading(false); } }; ``` --- ## Code Examples ### Hero Banner Component ```tsx
{settings?.organizationName || 'Changemaker Lite'} Join thousands taking action on the issues that matter
``` ### Representative Lookup Section ```tsx
Find Your Representatives } suffix={repsLoading ? : null} value={postalCode} onChange={(e) => setPostalCode(e.target.value)} onBlur={handlePostalCodeLookup} onPressEnter={handlePostalCodeLookup} style={{ maxWidth: 500, display: 'block', margin: '0 auto 24px' }} /> {representatives.length > 0 && ( {representatives.map((rep, idx) => (
{rep.name} {rep.name} {rep.elected_office} • {rep.district_name}
{rep.party_name}
Email:
{rep.email}

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

Address:
{rep.offices[0].postal} )}
))}
)}
``` ### Featured Campaign Card ```tsx {featuredCampaign && ( {featuredCampaign.coverPhoto ? ( {featuredCampaign.title} ) : (
)}
Featured Campaign
} > {featuredCampaign.title} {featuredCampaign.description}
{featuredCampaign.governmentLevel.map(level => ( {level.charAt(0).toUpperCase() + level.slice(1)} ))}
{featuredCampaign.emailsSentCount}
Emails Sent
{featuredCampaign.responsesCount}
Responses
)} ``` ### Regular Campaign Cards ```tsx {regularCampaigns.map((campaign) => ( {campaign.coverPhoto ? ( {campaign.title} ) : (
)}
} > {campaign.title} {campaign.description || 'No description available'}
{campaign.governmentLevel.map(level => ( {level.charAt(0).toUpperCase() + level.slice(1)} ))}
{campaign.emailsSentCount} sent {campaign.responsesCount} responses
))} ``` ### Empty State ```tsx {!loading && campaigns.length === 0 && (
No campaigns available Check back soon for new advocacy opportunities!
)} ``` --- ## Performance Considerations ### 1. Parallel Data Fetching Campaigns and settings fetched simultaneously using `Promise.all()`: ```tsx const [campaignsRes, settingsRes] = await Promise.all([ axios.get('/api/public/campaigns'), axios.get('/api/settings') ]); ``` **Benefit**: Reduces initial page load time by ~50% vs sequential requests. ### 2. Image Loading Optimization - **Object-fit**: `objectFit: 'cover'` prevents layout shift - **Fixed Heights**: Cover photos have defined heights (300px featured, 200px regular) - **Fallback Gradients**: Instant render when no cover photo exists - **Lazy Loading**: Browser-native lazy loading for off-screen images (future enhancement) ### 3. Conditional Rendering Representative lookup section only renders when results exist: ```tsx {representatives.length > 0 && ( {/* Rep cards */} )} ``` **Benefit**: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive). ### 4. Responsive Grid Optimization Ant Design Grid uses CSS Grid under the hood: ```tsx ``` **Benefit**: No JavaScript-based layout calculations, pure CSS performance. ### 5. Memoization Opportunities (Future Enhancement) Featured/regular campaign split could use `useMemo`: ```tsx const { featuredCampaign, regularCampaigns } = useMemo(() => ({ featuredCampaign: campaigns.find(c => c.isFeatured), regularCampaigns: campaigns.filter(c => !c.isFeatured) }), [campaigns]); ``` --- ## Responsive Design ### Breakpoint Behavior ```tsx const screens = useBreakpoint(); const isMobile = !screens.md; // md breakpoint = 768px ``` | Breakpoint | Hero Padding | Hero Font | Grid Columns | Rep Cards | |------------|-------------|-----------|--------------|-----------| | xs (0-575px) | 60px 20px | 24px | 1 | 1 | | sm (576-767px) | 60px 20px | 24px | 2 | 2 | | md (768-991px) | 80px 40px | 32px | 2 | 2 | | lg (992px+) | 80px 40px | 32px | 3 | 3 | ### Mobile Adaptations **Hero Banner:** - Reduced padding (60px vs 80px vertical) - Smaller title font (24px vs 32px) - Maintained gradient for visual impact **Representative Cards:** - Stack to single column on mobile - Maintain circular avatar size (150px) - Full-width buttons for better touch targets **Campaign Cards:** - Single column layout on mobile - Cover photo height remains 200px (cropped if needed) - Action buttons become full-width **Find Your Representatives Input:** - Full-width on mobile (maxWidth: 500px on desktop) - Larger touch target (size="large") - Enter key triggers lookup for better mobile UX ### Tablet Optimization At `sm` breakpoint (576-767px): - Campaign grid shows 2 columns - Representative cards show 2 per row - Hero banner uses mobile padding but desktop font size - Maintains visual hierarchy without overwhelming narrow viewports --- ## Accessibility ### Keyboard Navigation **Interactive Elements:** - All buttons and links focusable via Tab key - Postal code input supports Enter key submission - Card hover states also apply on keyboard focus **Focus Management:** ```tsx ``` ### ARIA Labels **Representative Photos:** ```tsx {`Photo ``` **Loading States:** ```tsx ``` **Icon Buttons:** ```tsx ``` ### Screen Reader Support **Structural Headings:** - Page uses semantic heading hierarchy (h1 → h2 → h3 → h4) - Hero uses `` for main page title - Sections use `<Title level={2}>` for logical grouping **Empty States:** - Informative messages for "No campaigns" and "No representatives found" - Visual icons paired with text labels **Statistics:** ```tsx <Text strong>{campaign.emailsSentCount}</Text> <br /> <Text type="secondary">Emails Sent</Text> // Screen reader announces: "1247 Emails Sent" ``` ### Color Contrast **Dark Theme Compliance:** - Background `#0d1b2a` with white text meets WCAG AA (7.8:1 ratio) - Links use `#1890ff` with sufficient contrast (4.6:1 ratio) - Tag colors (blue, purple, gold) all meet AA standards **Interactive States:** - Hover effects use opacity changes (accessible to screen readers) - Focus states use browser default outline (visible on all elements) --- ## Troubleshooting ### Issue: Representatives Not Loading **Symptoms:** - Postal code input shows no results - Console shows 404 or 500 error - Loading spinner stuck **Causes:** 1. Invalid postal code format (must be Canadian: `A1A 1A1`) 2. Represent API rate limiting (429 response) 3. Redis cache connection failure 4. Network timeout **Solutions:** ```tsx // Add postal code validation const isValidPostalCode = (code: string) => { const regex = /^[A-Z]\d[A-Z]\s?\d[A-Z]\d$/i; return regex.test(code); }; const handlePostalCodeLookup = async () => { const cleanCode = postalCode.trim().toUpperCase(); if (!isValidPostalCode(cleanCode)) { message.error('Please enter a valid Canadian postal code (e.g., K1A 0B1)'); return; } try { setRepsLoading(true); const response = await axios.get('/api/public/representatives/lookup', { params: { postalCode: cleanCode }, timeout: 10000 // 10s timeout }); setRepresentatives(response.data); } catch (error: any) { if (error.code === 'ECONNABORTED') { message.error('Request timed out. Please try again.'); } else if (error.response?.status === 429) { message.error('Too many requests. Please wait a moment and try again.'); } else { message.error('Failed to find representatives. Please try again later.'); } console.error('Lookup error:', error); } finally { setRepsLoading(false); } }; ``` ### Issue: Cover Photos Not Displaying **Symptoms:** - Campaign cards show gradient instead of uploaded photos - Console shows CORS errors - Broken image icons **Causes:** 1. Invalid image URL in database 2. CORS policy blocking external images 3. Image file deleted from storage 4. Incorrect Nginx configuration **Solutions:** ```tsx // Add image error handling const [imageErrors, setImageErrors] = useState<Set<string>>(new Set()); const handleImageError = (campaignId: string) => { setImageErrors(prev => new Set(prev).add(campaignId)); }; // In card cover render: cover={ <div style={{ height: 200, overflow: 'hidden' }}> {campaign.coverPhoto && !imageErrors.has(campaign.id) ? ( <img src={campaign.coverPhoto} alt={campaign.title} onError={() => handleImageError(campaign.id)} style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> ) : ( <div style={{ width: '100%', height: '100%', background: 'linear-gradient(135deg, #3498db 0%, #8e44ad 100%)' }} /> )} </div> } ``` **Check Nginx configuration:** ```nginx # In nginx/conf.d/default.conf location /uploads/ { add_header Access-Control-Allow-Origin *; add_header Access-Control-Allow-Methods "GET, OPTIONS"; } ``` ### Issue: Featured Campaign Not Appearing First **Symptoms:** - Featured campaign appears in middle/end of grid - Gold border not visible - Star icon missing **Causes:** 1. `isFeatured` flag not set in database 2. Multiple campaigns marked as featured 3. Grid rendering logic error **Solutions:** ```tsx // Add debug logging useEffect(() => { if (campaigns.length > 0) { const featured = campaigns.filter(c => c.isFeatured); console.log(`Found ${featured.length} featured campaigns:`, featured); if (featured.length > 1) { console.warn('Multiple campaigns marked as featured! Only first will display.'); } } }, [campaigns]); // Ensure only one featured campaign const featuredCampaign = campaigns.find(c => c.isFeatured); const regularCampaigns = campaigns.filter(c => !c.isFeatured); // Render in correct order <Row gutter={[24, 24]}> {featuredCampaign && ( <Col span={24} key={featuredCampaign.id}> {/* Featured card */} </Col> )} {regularCampaigns.map((campaign) => ( <Col xs={24} sm={12} lg={8} key={campaign.id}> {/* Regular card */} </Col> ))} </Row> ``` **Check database:** ```sql -- Find all featured campaigns SELECT id, title, "isFeatured" FROM "Campaign" WHERE "isFeatured" = true AND "isActive" = true; -- Fix multiple featured campaigns (keep most recent) UPDATE "Campaign" SET "isFeatured" = false WHERE "isFeatured" = true AND id != ( SELECT id FROM "Campaign" WHERE "isFeatured" = true ORDER BY "updatedAt" DESC LIMIT 1 ); ``` ### Issue: ShareButtons Not Working **Symptoms:** - Clicking share icons does nothing - "Copy Link" doesn't copy to clipboard - No new windows opening **Causes:** 1. Popup blockers preventing window.open() 2. Clipboard API not available (non-HTTPS) 3. ShareButtons component not imported 4. Missing event handlers **Solutions:** ```tsx // Ensure HTTPS for clipboard API if (!navigator.clipboard) { console.warn('Clipboard API requires HTTPS'); // Fallback to textarea copy method } // Add user interaction check for popups const handleShare = (platform: string) => { // Must be triggered by user action (not async callback) const url = encodeURIComponent(window.location.href); const title = encodeURIComponent('Check out these advocacy campaigns!'); let shareUrl = ''; switch (platform) { case 'twitter': shareUrl = `https://twitter.com/intent/tweet?url=${url}&text=${title}`; break; case 'facebook': shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${url}`; break; // ... other platforms } const popup = window.open(shareUrl, '_blank', 'width=600,height=400'); if (!popup) { message.warning('Please allow popups to share on social media'); } }; ``` ### Issue: Page Loading Very Slowly **Symptoms:** - Spinner shows for 5+ seconds - Network tab shows slow API responses - Images take long to load **Causes:** 1. Large campaign list (100+ campaigns) 2. High-resolution cover photos (5MB+ files) 3. No database indexes on `isActive` column 4. N+1 query problem (not in this case, single query) **Solutions:** **Add pagination (API change required):** ```tsx const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const pageSize = 12; useEffect(() => { const fetchCampaigns = async () => { try { setLoading(true); const response = await axios.get('/api/public/campaigns', { params: { page, limit: pageSize } }); setCampaigns(response.data.campaigns); setTotal(response.data.total); } catch (error) { console.error('Failed to fetch campaigns:', error); } finally { setLoading(false); } }; fetchCampaigns(); }, [page]); // Add Pagination component <Pagination current={page} total={total} pageSize={pageSize} onChange={setPage} style={{ marginTop: 24, textAlign: 'center' }} /> ``` **Optimize images server-side:** ```bash # Add image resizing in upload pipeline # Max width: 1200px, quality: 80% convert input.jpg -resize 1200x -quality 80 output.jpg ``` **Add database index:** ```sql CREATE INDEX idx_campaign_active_featured ON "Campaign" ("isActive", "isFeatured", "updatedAt" DESC); ``` --- ## Related Documentation ### Public Pages - [Campaign Detail Page](./campaign-page.md) - Individual campaign view with email sending - [Response Wall Page](./response-wall-page.md) - Public response submission and display - [Map Page](./map-page.md) - Public location map ### Admin Pages - [Campaigns Management](../admin/campaigns-page.md) - Campaign CRUD and configuration - [Representatives Admin](../admin/representatives-page.md) - Rep cache management - [Settings Page](../admin/settings-page.md) - Organization name configuration ### Components - [PublicLayout](../../components/public-layout.md) - Dark theme layout wrapper - [ShareButtons](../../components/share-buttons.md) - Social sharing component ### API Documentation - [Public Campaigns API](../../../api/modules/influence/campaigns-public-routes.md) - [Representatives API](../../../api/modules/influence/representatives-routes.md) - [Settings API](../../../api/modules/settings/settings-routes.md) ### Architecture - [V2 Architecture Overview](../../../architecture/v2-architecture.md) - [Public vs Admin Routing](../../../architecture/routing.md) - [Ant Design Theme Configuration](../../../architecture/theme-config.md)