32 KiB
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 #f39c12with 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
- User navigates to
/campaigns - PublicLayout renders with dark theme
- Component fetches settings from
/api/settings - Component fetches campaigns from
/api/public/campaigns - Hero banner displays organization name
- Campaigns grid renders with featured campaign (if exists) highlighted
- ShareButtons component appears at bottom
Representative Lookup Flow
- User enters postal code in "Find Your Representatives" input
- On blur or Enter key, component triggers lookup
- Loading spinner appears in input suffix
- API request to
/api/public/representatives/lookup?postalCode=X - Results display in grid format with rep cards
- User can view contact details for each representative
- Empty state message if no results found
Campaign Browsing
- User scrolls through campaigns grid
- Featured campaign (if exists) appears first with gold border
- User clicks "View Campaign" on any card
- Navigation to
/campaigns/:iddetail page - Statistics update dynamically based on campaign activity
Social Sharing
- User scrolls to bottom of page
- User clicks desired social platform icon
- Platform-specific share dialog opens (new window)
- For "Copy Link", URL copied to clipboard with notification
- User can share to multiple platforms sequentially
Component Structure
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<Campaign[]>([]);
const [settings, setSettings] = useState<Settings | null>(null);
const [loading, setLoading] = useState(true);
const [postalCode, setPostalCode] = useState('');
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [repsLoading, setRepsLoading] = useState(false);
const screens = useBreakpoint();
const isMobile = !screens.md;
// Data fetching, event handlers, etc.
return (
<PublicLayout>
{/* Hero Banner */}
<div className="hero-banner">
{/* Content */}
</div>
{/* Find Your Representatives */}
<div className="find-reps-section">
{/* Postal code input and results */}
</div>
{/* Campaigns Grid */}
<div className="campaigns-grid">
<Row gutter={[24, 24]}>
{/* Featured campaign */}
{/* Regular campaigns */}
</Row>
</div>
{/* Social Sharing */}
<ShareButtons
url={window.location.href}
title="Check out these advocacy campaigns!"
/>
</PublicLayout>
);
};
export default CampaignsListPage;
State Management
Component State
// Campaign data state
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [loading, setLoading] = useState(true);
// Settings state
const [settings, setSettings] = useState<Settings | null>(null);
// Representative lookup state
const [postalCode, setPostalCode] = useState('');
const [representatives, setRepresentatives] = useState<Representative[]>([]);
const [repsLoading, setRepsLoading] = useState(false);
// Responsive design state
const screens = useBreakpoint();
const isMobile = !screens.md;
Derived State
// 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
- Initial Load:
loading=true, fetch campaigns and settings in parallel - Data Received:
setCampaigns(),setSettings(),setLoading(false) - Postal Code Entry: User types,
setPostalCode()updates state - Lookup Trigger: On blur/Enter,
setRepsLoading(true), fetch reps - Reps Received:
setRepresentatives(),setRepsLoading(false) - Error Handling: Display message.error(), reset loading states
API Integration
Endpoints Used
1. Get Settings
GET /api/settings
Response:
{
"organizationName": "Progressive Action Network",
"contactEmail": "contact@example.org",
"allowPublicRegistration": true,
"defaultMapCenter": [45.5017, -73.5673],
"defaultMapZoom": 12
}
2. List Public Campaigns
GET /api/public/campaigns
Response:
[
{
"id": "cm1abc123",
"title": "Support Climate Action Bill",
"description": "Urge your representatives to support strong climate legislation",
"slug": "climate-action-bill",
"coverPhoto": "https://example.com/photos/climate.jpg",
"governmentLevel": ["federal"],
"targetType": "representatives",
"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
GET /api/public/representatives/lookup?postalCode=K1A0B1
Response:
[
{
"name": "John Smith",
"district_name": "Ottawa Centre",
"elected_office": "MP",
"party_name": "Liberal",
"email": "john.smith@parl.gc.ca",
"photo_url": "https://represent.opennorth.ca/media/photos/mp-john-smith.jpg",
"offices": [
{
"tel": "613-555-1234",
"type": "constituency",
"postal": "123 Main St, Ottawa ON K1A 0B1"
}
],
"government_level": "federal"
}
]
Request Examples
Fetch Campaigns
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
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
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: isMobile ? '60px 20px' : '80px 40px',
textAlign: 'center',
marginBottom: 48,
borderRadius: 8
}}>
<Title
level={1}
style={{
color: 'white',
marginBottom: 16,
fontSize: isMobile ? 24 : 32
}}
>
{settings?.organizationName || 'Changemaker Lite'}
</Title>
<Paragraph
style={{
color: 'rgba(255,255,255,0.9)',
fontSize: isMobile ? 16 : 18,
maxWidth: 600,
margin: '0 auto'
}}
>
<MailOutlined style={{ marginRight: 8 }} />
Join thousands taking action on the issues that matter
</Paragraph>
</div>
Representative Lookup Section
<div style={{
background: theme.token.colorBgContainer,
padding: isMobile ? 24 : 40,
borderRadius: 8,
marginBottom: 48
}}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 24 }}>
Find Your Representatives
</Title>
<Input
size="large"
placeholder="Enter your postal code (e.g., K1A 0B1)"
prefix={<SearchOutlined />}
suffix={repsLoading ? <Spin size="small" /> : 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 && (
<Row gutter={[16, 16]}>
{representatives.map((rep, idx) => (
<Col xs={24} sm={12} lg={8} key={idx}>
<Card hoverable>
<div style={{ textAlign: 'center' }}>
<img
src={rep.photo_url || '/default-avatar.png'}
alt={rep.name}
style={{
width: 150,
height: 150,
borderRadius: '50%',
objectFit: 'cover',
marginBottom: 16
}}
/>
<Title level={4} style={{ marginBottom: 4 }}>
{rep.name}
</Title>
<Text type="secondary">
{rep.elected_office} • {rep.district_name}
</Text>
<div style={{ marginTop: 12 }}>
<Tag color="blue">{rep.party_name}</Tag>
</div>
<div style={{ marginTop: 16, textAlign: 'left' }}>
<Text strong>Email:</Text>
<br />
<Text copyable>{rep.email}</Text>
<br /><br />
{rep.offices?.[0] && (
<>
<Text strong>Phone:</Text>
<br />
<Text>{rep.offices[0].tel}</Text>
<br /><br />
<Text strong>Address:</Text>
<br />
<Text type="secondary">{rep.offices[0].postal}</Text>
</>
)}
</div>
</div>
</Card>
</Col>
))}
</Row>
)}
</div>
Featured Campaign Card
{featuredCampaign && (
<Col span={24} key={featuredCampaign.id}>
<Card
hoverable
style={{
border: '2px solid #f39c12',
boxShadow: '0 4px 12px rgba(243, 156, 18, 0.3)',
position: 'relative'
}}
cover={
<div style={{ position: 'relative', height: 300, overflow: 'hidden' }}>
{featuredCampaign.coverPhoto ? (
<img
src={featuredCampaign.coverPhoto}
alt={featuredCampaign.title}
style={{
width: '100%',
height: '100%',
objectFit: 'cover'
}}
/>
) : (
<div style={{
width: '100%',
height: '100%',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}} />
)}
<div style={{
position: 'absolute',
top: 16,
right: 16,
background: 'rgba(243, 156, 18, 0.9)',
color: 'white',
padding: '8px 16px',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
gap: 8
}}>
<StarFilled />
<Text strong style={{ color: 'white' }}>
Featured Campaign
</Text>
</div>
</div>
}
>
<Title level={3} style={{ marginBottom: 12 }}>
{featuredCampaign.title}
</Title>
<Paragraph
ellipsis={{ rows: 2 }}
style={{ marginBottom: 16 }}
>
{featuredCampaign.description}
</Paragraph>
<div style={{ marginBottom: 16 }}>
{featuredCampaign.governmentLevel.map(level => (
<Tag key={level} color="blue">
{level.charAt(0).toUpperCase() + level.slice(1)}
</Tag>
))}
</div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={12}>
<div style={{ textAlign: 'center' }}>
<MailOutlined style={{ fontSize: 24, color: '#1890ff' }} />
<div>
<Text strong>{featuredCampaign.emailsSentCount}</Text>
<br />
<Text type="secondary">Emails Sent</Text>
</div>
</div>
</Col>
<Col span={12}>
<div style={{ textAlign: 'center' }}>
<CommentOutlined style={{ fontSize: 24, color: '#52c41a' }} />
<div>
<Text strong>{featuredCampaign.responsesCount}</Text>
<br />
<Text type="secondary">Responses</Text>
</div>
</div>
</Col>
</Row>
<Link to={`/campaigns/${featuredCampaign.id}`}>
<Button type="primary" block size="large">
View Campaign
</Button>
</Link>
</Card>
</Col>
)}
Regular Campaign Cards
{regularCampaigns.map((campaign) => (
<Col xs={24} sm={12} lg={8} key={campaign.id}>
<Card
hoverable
cover={
<div style={{ height: 200, overflow: 'hidden' }}>
{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, #3498db 0%, #8e44ad 100%)'
}} />
)}
</div>
}
>
<Title level={4} style={{ marginBottom: 8 }}>
{campaign.title}
</Title>
<Paragraph
ellipsis={{ rows: 2 }}
type="secondary"
style={{ marginBottom: 12, minHeight: 44 }}
>
{campaign.description || 'No description available'}
</Paragraph>
<div style={{ marginBottom: 12 }}>
{campaign.governmentLevel.map(level => (
<Tag key={level} color="purple">
{level.charAt(0).toUpperCase() + level.slice(1)}
</Tag>
))}
</div>
<Row gutter={8} style={{ marginBottom: 12, fontSize: 12 }}>
<Col span={12}>
<MailOutlined /> {campaign.emailsSentCount} sent
</Col>
<Col span={12}>
<CommentOutlined /> {campaign.responsesCount} responses
</Col>
</Row>
<Link to={`/campaigns/${campaign.id}`}>
<Button type="link" block>
View Campaign →
</Button>
</Link>
</Card>
</Col>
))}
Empty State
{!loading && campaigns.length === 0 && (
<div style={{
textAlign: 'center',
padding: 60,
background: theme.token.colorBgContainer,
borderRadius: 8
}}>
<InboxOutlined style={{ fontSize: 64, color: '#999', marginBottom: 16 }} />
<Title level={3} type="secondary">
No campaigns available
</Title>
<Paragraph type="secondary">
Check back soon for new advocacy opportunities!
</Paragraph>
</div>
)}
Performance Considerations
1. Parallel Data Fetching
Campaigns and settings fetched simultaneously using Promise.all():
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:
{representatives.length > 0 && (
<Row gutter={[16, 16]}>
{/* Rep cards */}
</Row>
)}
Benefit: Avoids unnecessary DOM nodes and improves TTI (Time to Interactive).
4. Responsive Grid Optimization
Ant Design Grid uses CSS Grid under the hood:
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
Benefit: No JavaScript-based layout calculations, pure CSS performance.
5. Memoization Opportunities (Future Enhancement)
Featured/regular campaign split could use useMemo:
const { featuredCampaign, regularCampaigns } = useMemo(() => ({
featuredCampaign: campaigns.find(c => c.isFeatured),
regularCampaigns: campaigns.filter(c => !c.isFeatured)
}), [campaigns]);
Responsive Design
Breakpoint Behavior
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:
<Input
onPressEnter={handlePostalCodeLookup}
// Focus indicator via Ant Design theme
/>
ARIA Labels
Representative Photos:
<img
src={rep.photo_url || '/default-avatar.png'}
alt={`Photo of ${rep.name}, ${rep.elected_office} for ${rep.district_name}`}
// Descriptive alt text for screen readers
/>
Loading States:
<Spin size="small" aria-label="Loading representatives" />
Icon Buttons:
<Button
icon={<SearchOutlined />}
aria-label="Search for representatives"
>
Find Representatives
</Button>
Screen Reader Support
Structural Headings:
- Page uses semantic heading hierarchy (h1 → h2 → h3 → h4)
- Hero uses
<Title level={1}>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:
<Text strong>{campaign.emailsSentCount}</Text>
<br />
<Text type="secondary">Emails Sent</Text>
// Screen reader announces: "1247 Emails Sent"
Color Contrast
Dark Theme Compliance:
- Background
#0d1b2awith white text meets WCAG AA (7.8:1 ratio) - Links use
#1890ffwith 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:
- Invalid postal code format (must be Canadian:
A1A 1A1) - Represent API rate limiting (429 response)
- Redis cache connection failure
- Network timeout
Solutions:
// 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:
- Invalid image URL in database
- CORS policy blocking external images
- Image file deleted from storage
- Incorrect Nginx configuration
Solutions:
// 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:
# 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:
isFeaturedflag not set in database- Multiple campaigns marked as featured
- Grid rendering logic error
Solutions:
// 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:
-- 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:
- Popup blockers preventing window.open()
- Clipboard API not available (non-HTTPS)
- ShareButtons component not imported
- Missing event handlers
Solutions:
// 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:
- Large campaign list (100+ campaigns)
- High-resolution cover photos (5MB+ files)
- No database indexes on
isActivecolumn - N+1 query problem (not in this case, single query)
Solutions:
Add pagination (API change required):
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:
# Add image resizing in upload pipeline
# Max width: 1200px, quality: 80%
convert input.jpg -resize 1200x -quality 80 output.jpg
Add database index:
CREATE INDEX idx_campaign_active_featured
ON "Campaign" ("isActive", "isFeatured", "updatedAt" DESC);
Related Documentation
Public Pages
- Campaign Detail Page - Individual campaign view with email sending
- Response Wall Page - Public response submission and display
- Map Page - Public location map
Admin Pages
- Campaigns Management - Campaign CRUD and configuration
- Representatives Admin - Rep cache management
- Settings Page - Organization name configuration
Components
- PublicLayout - Dark theme layout wrapper
- ShareButtons - Social sharing component