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

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

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

  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

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>
{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 #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:

// 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:

// 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";
}

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:

// 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:

  1. Popup blockers preventing window.open()
  2. Clipboard API not available (non-HTTPS)
  3. ShareButtons component not imported
  4. 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:

  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):

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);

Public Pages

Admin Pages

Components

API Documentation

Architecture