25 KiB

CampaignsPage

Overview

The CampaignsPage provides complete CRUD management for advocacy email campaigns in the Influence module. It displays campaigns in a paginated table with search, status filtering, and quick actions for viewing, editing, deleting, and accessing email statistics. Features include campaign highlighting, government level targeting, and comprehensive feature flags for customizing campaign behavior.

Route: /app/influence/campaigns Component: admin/src/pages/CampaignsPage.tsx (507 lines) Auth Required: Yes (SUPER_ADMIN, INFLUENCE_ADMIN roles) Layout: AppLayout

Screenshot

[Screenshot: Campaigns page with search bar at top left, status filter dropdown at top right, and "Create Campaign" button in page header. Main table shows columns: Title (with highlighted star icon for featured campaigns + public slug), Status (colored tags), Gov. Levels (multiple colored tags), Emails (count), Responses (count), Created (date), and Actions (5 icon buttons: view public page, copy link, view emails, edit, delete). Below table is pagination showing "X campaigns" total.]

Features

  • Full CRUD operations — Create, read, update, delete campaigns
  • Advanced search — 300ms debounced search by title or description
  • Status filtering — Filter by DRAFT, ACTIVE, PAUSED, ARCHIVED
  • Campaign highlighting — Star icon indicates featured campaigns (highlightCampaign flag)
  • Government level tags — Visual tags for FEDERAL, PROVINCIAL, MUNICIPAL, SCHOOL_BOARD
  • Email statistics — Click MailOutlined icon to open emails drawer with campaign email stats
  • Public link management — Copy campaign public link, view public page (ACTIVE only)
  • Comprehensive feature flags — 9 boolean toggles for campaign behavior:
    • Allow SMTP Email (send via queue)
    • Allow Mailto Link (browser email client)
    • Collect User Info (name, email, postal code)
    • Show Email Count (display total emails sent)
    • Show Call Count (display total calls made)
    • Allow Email Editing (user can edit template)
    • Allow Custom Recipients (user can add custom reps)
    • Show Response Wall (public response submission + display)
    • Highlight Campaign (featured on public campaigns list)
  • Color-coded statuses — Visual distinction between draft, active, paused, archived
  • Responsive table — Columns hide on smaller screens (Gov. Levels: md+, Responses: lg+, Created: md+)
  • Delete confirmation — Warns that associated emails and responses will also be deleted

User Workflow

Viewing Campaigns List

  1. Navigate to /app/influence/campaigns
  2. Page loads first 20 campaigns (pagination)
  3. View campaign stats: Emails count, Responses count
  4. See campaign status with colored tags
  5. Identify featured campaigns by star icon (highlightCampaign)
  6. Note public URL slug below campaign title

Creating a New Campaign

  1. Click "Create Campaign" button in page header
  2. Modal opens (640px width) with vertical form
  3. Fill required fields:
    • Title (auto-generates slug from title)
    • Email Subject
    • Email Body (template shown to users)
  4. Fill optional fields:
    • Description (internal note, not shown to public)
    • Call to Action (additional instructions for users)
    • Government Levels (multi-select: Federal, Provincial, Municipal, School Board)
    • Cover Photo URL (hero image on public campaign page)
    • Status (default: DRAFT)
  5. Configure feature flags (9 switches in 2-column grid):
    • Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount
    • Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign
  6. Click "Create" button
  7. Success message: "Campaign created"
  8. Modal closes, table refreshes to page 1
  9. New campaign appears at top (most recent first)

Editing an Existing Campaign

  1. Locate campaign in table
  2. Click Edit icon button (EditOutlined) in Actions column
  3. Edit modal opens (640px width) with pre-filled values
  4. Modify any fields (same form as create)
  5. Click "Save" button
  6. Success message: "Campaign updated"
  7. Modal closes, table refreshes with updated data
  8. If title changed, slug auto-updates

Viewing Campaign Emails

  1. Locate campaign in table
  2. Click Mail icon button (MailOutlined) in Actions column
  3. CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)
  4. View email statistics:
    • Total emails sent
    • Delivered, failed, pending counts
    • Email list with recipient, status, timestamp
  5. Click "X" to close drawer

Publishing a Campaign

  1. Open campaign in edit modal
  2. Change Status dropdown from DRAFT to ACTIVE
  3. Click "Save"
  4. Campaign now visible on public /campaigns page
  5. View icon button (EyeOutlined) now enabled
  6. Click View to open public campaign page in new tab
  1. Locate ACTIVE campaign in table
  2. Click Link icon button (LinkOutlined) in Actions column
  3. URL copied to clipboard: http://app.cmlite.org/campaign/{slug}
  4. Success message: "Campaign link copied"
  5. Share link with supporters

Searching and Filtering

  1. Use search bar at top left:
    • Type title or description keywords
    • 300ms debounce (waits for typing to stop)
    • Search resets pagination to page 1
  2. Use status filter dropdown at top right:
    • Select DRAFT, ACTIVE, PAUSED, or ARCHIVED
    • Filter resets pagination to page 1
    • Clear filter to show all campaigns
  3. Filters persist during pagination

Deleting a Campaign

  1. Locate campaign in table
  2. Click Delete icon button (DeleteOutlined) in Actions column
  3. Popconfirm appears: "Delete this campaign?"
  4. Description: "All associated emails and responses will also be deleted."
  5. Click "OK" to confirm
  6. Success message: "Campaign deleted"
  7. Table refreshes
  8. Associated CampaignEmail and Response records also deleted (cascade)

Component Breakdown

Ant Design Components Used

  • Table — Main campaigns list with columns, pagination, responsive breakpoints
  • Input — Search text input with SearchOutlined prefix icon
  • Select — Status filter dropdown with 4 options
  • Button — Create (primary), view, copy link, email stats, edit, delete actions
  • Modal — Create and edit campaign forms (destroyOnHidden)
  • Form — Vertical layout with all campaign fields
  • Form.Item — Individual field wrappers with labels, rules, help text
  • Input.TextArea — Multi-line fields (description, email body, call to action)
  • Row, Col — Responsive grid for status + gov levels (2 columns), feature flags (2 columns, 9 switches)
  • Switch — Boolean feature flag toggles with valuePropName="checked"
  • Tag — Status tags (color-coded), government level tags (color-coded)
  • Space — Action button grouping
  • Popconfirm — Delete confirmation with warning message
  • Divider — Feature flags section separator

Table Columns

const columns: ColumnsType<Campaign> = [
  {
    title: 'Title',
    dataIndex: 'title',
    key: 'title',
    render: (title, record) => (
      <div>
        <Space>
          <span style={{ fontWeight: 500 }}>{title}</span>
          {record.highlightCampaign && <StarFilled style={{ color: '#faad14' }} />}
        </Space>
        <div style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>/campaign/{record.slug}</div>
      </div>
    ),
  },
  {
    title: 'Status',
    dataIndex: 'status',
    render: (status) => <Tag color={statusColors[status]}>{status}</Tag>,
  },
  {
    title: 'Gov. Levels',
    dataIndex: 'targetGovernmentLevels',
    render: (levels: GovernmentLevel[]) =>
      levels.map((l) => <Tag key={l} color={govLevelColors[l]}>{l.replace('_', ' ')}</Tag>),
    responsive: ['md'],
  },
  {
    title: 'Emails',
    render: (_, record) => record._count.emails,
    responsive: ['md'],
  },
  {
    title: 'Responses',
    render: (_, record) => record._count.responses,
    responsive: ['lg'],
  },
  {
    title: 'Created',
    dataIndex: 'createdAt',
    render: (date) => dayjs(date).format('YYYY-MM-DD'),
    responsive: ['md'],
  },
  {
    title: 'Actions',
    render: (_, record) => (
      <Space>
        {/* View public page (ACTIVE only) */}
        {/* Copy link */}
        {/* View emails drawer */}
        {/* Edit modal */}
        {/* Delete popconfirm */}
      </Space>
    ),
  },
];

Key patterns:

  • _count aggregation fields from Prisma (emails, responses)
  • Responsive column visibility with responsive: ['md']
  • Conditional rendering: View button only for ACTIVE campaigns

Status Colors

const statusColors: Record<CampaignStatus, string> = {
  DRAFT: 'default',    // Gray
  ACTIVE: 'green',     // Green
  PAUSED: 'orange',    // Orange
  ARCHIVED: 'gray',    // Gray
};

Government Level Colors

const govLevelColors: Record<GovernmentLevel, string> = {
  FEDERAL: 'blue',
  PROVINCIAL: 'purple',
  MUNICIPAL: 'cyan',
  SCHOOL_BOARD: 'magenta',
};

Feature Flags Form Section

<Divider orientation="left" plain>Feature Flags</Divider>
<Row gutter={[16, 8]}>
  <Col xs={24} sm={12}>
    <Form.Item name="allowSmtpEmail" label="Allow SMTP Email" valuePropName="checked" initialValue={true}>
      <Switch />
    </Form.Item>
  </Col>
  <Col xs={24} sm={12}>
    <Form.Item name="allowMailtoLink" label="Allow Mailto Link" valuePropName="checked" initialValue={true}>
      <Switch />
    </Form.Item>
  </Col>
  {/* 7 more switches */}
</Row>

Pattern: 9 switches in 2-column responsive grid (xs: 1 column, sm+: 2 columns)

State Management

Zustand Stores Used

None — Campaigns are fetched from API on each page load. No global state required.

Local State

const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const [statusFilter, setStatusFilter] = useState<CampaignStatus | undefined>();
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editModalOpen, setEditModalOpen] = useState(false);
const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
const [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);
const [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();

Debounced search pattern:

const handleSearchChange = (value: string) => {
  setSearch(value);               // Update input immediately
  clearTimeout(searchTimerRef.current);
  searchTimerRef.current = setTimeout(() => setDebouncedSearch(value), 300);  // Debounce API call
};

useEffect(() => {
  fetchCampaigns({ page: 1 });
}, [debouncedSearch, statusFilter]);  // Re-fetch when debounced search or filter changes

useEffect(() => {
  return () => clearTimeout(searchTimerRef.current);  // Cleanup on unmount
}, []);

Why 300ms debounce? Prevents API spam while typing. Only fetches when user pauses.

API Integration

Endpoints Used

Method Endpoint Purpose
GET /api/campaigns List campaigns (paginated, filtered)
POST /api/campaigns Create campaign
PUT /api/campaigns/:id Update campaign
DELETE /api/campaigns/:id Delete campaign (cascade emails + responses)

List Campaigns

Request:

const { data } = await api.get<CampaignsListResponse>('/campaigns', {
  params: {
    page: 1,
    limit: 20,
    search: 'climate',       // Optional: search title/description
    status: 'ACTIVE',        // Optional: filter by status
  },
});

Response:

{
  "campaigns": [
    {
      "id": "cm-123",
      "title": "Contact Your MP About Climate Action",
      "slug": "contact-your-mp-about-climate-action",
      "description": "Urge federal representatives to support renewable energy legislation",
      "emailSubject": "Support Climate Action Now",
      "emailBody": "Dear [Representative Name],\n\nI am writing to urge you to support...",
      "callToAction": "Remember to follow up with a phone call next week!",
      "status": "ACTIVE",
      "targetGovernmentLevels": ["FEDERAL"],
      "allowSmtpEmail": true,
      "allowMailtoLink": true,
      "collectUserInfo": true,
      "showEmailCount": true,
      "showCallCount": false,
      "allowEmailEditing": false,
      "allowCustomRecipients": false,
      "showResponseWall": true,
      "highlightCampaign": true,
      "coverPhoto": "https://example.com/climate.jpg",
      "createdAt": "2026-01-15T10:30:00.000Z",
      "updatedAt": "2026-01-20T14:45:00.000Z",
      "_count": {
        "emails": 847,
        "responses": 23
      }
    }
  ],
  "pagination": {
    "page": 1,
    "limit": 20,
    "total": 12,
    "totalPages": 1
  }
}

Key fields:

  • slug — URL-friendly identifier (auto-generated from title)
  • targetGovernmentLevels — Array of government levels (empty array = all)
  • _count — Prisma aggregation with email and response counts
  • Feature flags — 9 boolean fields controlling campaign behavior

Create Campaign

Request:

const payload: CreateCampaignPayload = {
  title: "Stop Deforestation in Northern Ontario",
  description: "Internal campaign note",
  emailSubject: "Protect Our Forests",
  emailBody: "Dear [Representative Name],\n\nI urge you to...",
  callToAction: "Share this campaign on social media!",
  status: "DRAFT",
  targetGovernmentLevels: ["PROVINCIAL"],
  allowSmtpEmail: true,
  allowMailtoLink: true,
  collectUserInfo: true,
  showEmailCount: true,
  showCallCount: false,
  allowEmailEditing: false,
  allowCustomRecipients: false,
  showResponseWall: false,
  highlightCampaign: false,
  coverPhoto: "https://example.com/forest.jpg",
};

await api.post('/campaigns', payload);

Response:

{
  "id": "cm-456",
  "title": "Stop Deforestation in Northern Ontario",
  "slug": "stop-deforestation-in-northern-ontario",
  "status": "DRAFT",
  "createdAt": "2026-02-11T09:00:00.000Z",
  // ... all other fields
}

Slug generation: Backend auto-generates slug from title (lowercase, hyphens replace spaces/punctuation)

Update Campaign

Request:

const payload: UpdateCampaignPayload = {
  status: "ACTIVE",              // Publish campaign
  highlightCampaign: true,       // Feature on campaigns list
  showResponseWall: true,        // Enable response submissions
};

await api.put(`/campaigns/${campaignId}`, payload);

Response:

{
  "id": "cm-456",
  "status": "ACTIVE",
  "highlightCampaign": true,
  "showResponseWall": true,
  "updatedAt": "2026-02-11T10:15:00.000Z",
  // ... all other fields
}

Partial updates: Only send changed fields, backend merges with existing record.

Delete Campaign

Request:

await api.delete(`/campaigns/${campaignId}`);

Response: 204 No Content

Cascade behavior: Prisma cascade deletes:

  • All CampaignEmail records (sent emails)
  • All Response records (public responses)
  • All PostalCodeCache entries referencing this campaign

Warning: Shown in Popconfirm: "All associated emails and responses will also be deleted."

Code Examples

Debounced Search Implementation

const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const searchTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);

const handleSearchChange = (value: string) => {
  setSearch(value);                             // Update input immediately (controlled component)
  clearTimeout(searchTimerRef.current);         // Cancel previous timer
  searchTimerRef.current = setTimeout(() => {
    setDebouncedSearch(value);                  // Update debounced value after 300ms
  }, 300);
};

useEffect(() => {
  return () => clearTimeout(searchTimerRef.current);  // Cleanup timer on unmount
}, []);

useEffect(() => {
  fetchCampaigns({ page: 1 });                  // Re-fetch when debounced search changes
}, [debouncedSearch, statusFilter]);            // Also re-fetch when filter changes

Benefits:

  • User sees immediate feedback in input (controlled)
  • API only called once per 300ms (prevents spam)
  • Timer cleared on unmount (no memory leaks)

useCallback Optimization

const fetchCampaigns = useCallback(async (params?: CampaignsListParams) => {
  setLoading(true);
  try {
    const { data } = await api.get<CampaignsListResponse>('/campaigns', {
      params: {
        page: params?.page ?? pagination.page,
        limit: params?.limit ?? pagination.limit,
        search: params?.search ?? (debouncedSearch || undefined),
        status: params?.status ?? statusFilter,
      },
    });
    setCampaigns(data.campaigns);
    setPagination(data.pagination);
  } catch {
    message.error('Failed to load campaigns');
  } finally {
    setLoading(false);
  }
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);

Why useCallback? Memoizes function, prevents re-creating on every render. Dependencies array ensures function updates when pagination, search, or filter changes.

Color-Coded Government Level Tags

const govLevelColors: Record<GovernmentLevel, string> = {
  FEDERAL: 'blue',
  PROVINCIAL: 'purple',
  MUNICIPAL: 'cyan',
  SCHOOL_BOARD: 'magenta',
};

// In table column render:
{
  title: 'Gov. Levels',
  dataIndex: 'targetGovernmentLevels',
  render: (levels: GovernmentLevel[]) =>
    levels.length > 0
      ? levels.map((l) => (
          <Tag key={l} color={govLevelColors[l]} style={{ fontSize: 11 }}>
            {l.replace('_', ' ')}  // "SCHOOL_BOARD" → "SCHOOL BOARD"
          </Tag>
        ))
      : '--',
  responsive: ['md'],
}

Pattern: Map each government level to a colored tag, replace underscores with spaces for readability.

Reusable Form Fields Component

const campaignFormFields = (
  <>
    <Form.Item name="title" label="Title" rules={[{ required: true }]}>
      <Input />
    </Form.Item>
    <Form.Item name="description" label="Description">
      <TextArea rows={2} />
    </Form.Item>
    {/* ... all other fields */}
    <Divider orientation="left" plain>Feature Flags</Divider>
    <Row gutter={[16, 8]}>
      {/* 9 switches in 2-column grid */}
    </Row>
  </>
);

// Used in both create and edit modals:
<Form form={createForm} onFinish={handleCreate} layout="vertical">
  {campaignFormFields}
</Form>

<Form form={editForm} onFinish={handleEdit} layout="vertical">
  {campaignFormFields}
</Form>

Benefits:

  • DRY principle (Don't Repeat Yourself)
  • Single source of truth for form structure
  • Easy to add/modify fields in one place

Performance Considerations

300ms debounce prevents API spam:

  • User typing "climate action" fires 1 API call (not 14)
  • Reduces server load, improves responsiveness
  • Uses clearTimeout to cancel pending calls

Responsive Column Hiding

{
  title: 'Gov. Levels',
  responsive: ['md'],  // Hide on screens < 768px
}

Benefits:

  • Mobile users see only essential columns (Title, Status, Actions)
  • Desktop users see full details
  • No horizontal scrolling on mobile

useCallback Memoization

const fetchCampaigns = useCallback(async (params) => {
  // ... fetch logic
}, [pagination.page, pagination.limit, debouncedSearch, statusFilter]);

Benefits:

  • Function reference stable unless dependencies change
  • Prevents unnecessary re-renders in child components
  • Avoids infinite re-render loops

Pagination

Default 20 items per page:

  • Keeps initial load fast
  • User can change page size (10, 20, 50, 100)
  • Server-side pagination (not loading all campaigns at once)

Responsive Design

Mobile (< 576px)

  • Table: Single column layout
    • Title + star icon (if highlighted)
    • Status tag
    • Actions column (all 5 buttons visible)
    • Gov. Levels, Emails, Responses, Created columns hidden
  • Search bar: Full width
  • Status filter: Full width below search
  • Feature flags: Single column (xs={24})

Tablet (576px - 992px)

  • Table: Gov. Levels, Emails, Created columns visible
    • Responses column still hidden (lg+)
  • Search bar: Half width (sm={12})
  • Status filter: Quarter width (sm={6})
  • Feature flags: 2 columns (sm={12})

Desktop (≥ 992px)

  • Table: All columns visible
  • Filters: Compact layout (search 1/3 width, filter 1/6 width)
  • Feature flags: 2 columns with comfortable spacing

Accessibility

  • Keyboard navigation: All buttons, inputs, selects focusable via Tab
  • ARIA labels: Icon buttons have title attribute for tooltips
  • Form validation: Required fields marked with red asterisk, inline error messages
  • Color contrast: Status tags use Ant Design default colors (WCAG AA compliant)
  • Screen reader support: Form.Item labels properly associated with inputs
  • Focus management: Modal auto-focuses first input on open

Troubleshooting

Campaign Not Appearing on Public Page

Problem: Created campaign, set status to ACTIVE, but /campaigns page doesn't show it.

Diagnosis:

Check status in campaigns table:

campaigns.find((c) => c.slug === 'my-campaign')?.status  // Should be "ACTIVE"

Common Issues:

  1. Status still DRAFT:

    • Edit campaign
    • Change Status dropdown from DRAFT to ACTIVE
    • Click Save
  2. Browser cache:

    • Hard refresh public page (Ctrl+Shift+R)
    • Or clear browser cache
  3. Campaign created but not saved:

    • Check for error message after clicking Create
    • Verify required fields filled (Title, Email Subject, Email Body)

Solution: Always verify status is ACTIVE after creating campaign. Status defaults to DRAFT.


Emails Drawer Shows 0 Emails

Problem: Click Mail icon for ACTIVE campaign, drawer shows 0 emails.

Diagnosis:

Campaign might be active but no one has sent emails yet:

campaign._count.emails === 0  // No emails sent via this campaign

Common Issues:

  1. Campaign just published:

    • No users have accessed public page yet
    • Share campaign link to supporters
  2. SMTP not configured:

    • Check Settings → Email tab
    • Verify Production SMTP credentials
    • Test connection
  3. BullMQ queue not running:

    • Check docker-compose logs: docker compose logs email-worker
    • Verify redis container running

Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.


Problem: Click Link icon, no success message, clipboard empty.

Diagnosis:

Check browser console for errors:

DOMException: Document is not focused

Common Issue:

Browser security blocks clipboard access if page not focused.

Solution:

  1. Click anywhere on page to focus
  2. Retry Copy Link button
  3. Or manually copy slug from table: /campaign/{slug}

Duplicate Campaign Titles

Problem: Create campaign with same title as existing, backend allows it.

Diagnosis:

Backend auto-generates unique slug by appending numbers:

"Climate Action" → "climate-action"
"Climate Action" (duplicate) → "climate-action-1"
"Climate Action" (duplicate 2) → "climate-action-2"

Not an error: Duplicate titles allowed, slugs remain unique.

Best Practice: Use unique, descriptive titles to avoid confusion:

  • "Climate Action" (generic)
  • "Climate Action: Support Bill C-12" (specific)

Delete Confirmation Not Showing

Problem: Click Delete icon, campaign deletes immediately without confirmation.

Diagnosis:

Check Popconfirm placement in table Actions column:

<Popconfirm
  title="Delete this campaign?"
  description="All associated emails and responses will also be deleted."
  onConfirm={() => handleDelete(record.id)}
>
  <Button type="link" size="small" danger icon={<DeleteOutlined />} title="Delete" />
</Popconfirm>

Solution: Popconfirm wraps the Button. If Popconfirm missing, delete happens immediately. Always use Popconfirm for destructive actions.