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
- Navigate to
/app/influence/campaigns - Page loads first 20 campaigns (pagination)
- View campaign stats: Emails count, Responses count
- See campaign status with colored tags
- Identify featured campaigns by star icon (highlightCampaign)
- Note public URL slug below campaign title
Creating a New Campaign
- Click "Create Campaign" button in page header
- Modal opens (640px width) with vertical form
- Fill required fields:
- Title (auto-generates slug from title)
- Email Subject
- Email Body (template shown to users)
- 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)
- Configure feature flags (9 switches in 2-column grid):
- Default ON: allowSmtpEmail, allowMailtoLink, collectUserInfo, showEmailCount, showCallCount
- Default OFF: allowEmailEditing, allowCustomRecipients, showResponseWall, highlightCampaign
- Click "Create" button
- Success message: "Campaign created"
- Modal closes, table refreshes to page 1
- New campaign appears at top (most recent first)
Editing an Existing Campaign
- Locate campaign in table
- Click Edit icon button (EditOutlined) in Actions column
- Edit modal opens (640px width) with pre-filled values
- Modify any fields (same form as create)
- Click "Save" button
- Success message: "Campaign updated"
- Modal closes, table refreshes with updated data
- If title changed, slug auto-updates
Viewing Campaign Emails
- Locate campaign in table
- Click Mail icon button (MailOutlined) in Actions column
- CampaignEmailsDrawer opens on right side (see CampaignEmailsDrawer)
- View email statistics:
- Total emails sent
- Delivered, failed, pending counts
- Email list with recipient, status, timestamp
- Click "X" to close drawer
Publishing a Campaign
- Open campaign in edit modal
- Change Status dropdown from DRAFT to ACTIVE
- Click "Save"
- Campaign now visible on public
/campaignspage - View icon button (EyeOutlined) now enabled
- Click View to open public campaign page in new tab
Copying Public Campaign Link
- Locate ACTIVE campaign in table
- Click Link icon button (LinkOutlined) in Actions column
- URL copied to clipboard:
http://app.cmlite.org/campaign/{slug} - Success message: "Campaign link copied"
- Share link with supporters
Searching and Filtering
- Use search bar at top left:
- Type title or description keywords
- 300ms debounce (waits for typing to stop)
- Search resets pagination to page 1
- 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
- Filters persist during pagination
Deleting a Campaign
- Locate campaign in table
- Click Delete icon button (DeleteOutlined) in Actions column
- Popconfirm appears: "Delete this campaign?"
- Description: "All associated emails and responses will also be deleted."
- Click "OK" to confirm
- Success message: "Campaign deleted"
- Table refreshes
- 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:
_countaggregation 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
Debounced Search
300ms debounce prevents API spam:
- User typing "climate action" fires 1 API call (not 14)
- Reduces server load, improves responsiveness
- Uses
clearTimeoutto 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
titleattribute 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:
-
Status still DRAFT:
- Edit campaign
- Change Status dropdown from DRAFT to ACTIVE
- Click Save
-
Browser cache:
- Hard refresh public page (Ctrl+Shift+R)
- Or clear browser cache
-
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:
-
Campaign just published:
- No users have accessed public page yet
- Share campaign link to supporters
-
SMTP not configured:
- Check Settings → Email tab
- Verify Production SMTP credentials
- Test connection
-
BullMQ queue not running:
- Check docker-compose logs:
docker compose logs email-worker - Verify redis container running
- Check docker-compose logs:
Solution: Emails drawer shows historical data. If campaign is new, wait for users to send emails via public page.
Copy Link Button Not Working
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:
- Click anywhere on page to focus
- Retry Copy Link button
- 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.
Related Documentation
- Campaigns Module (Backend) — API implementation, schemas, service functions
- Campaign Emails Module — Email tracking, stats, sent emails
- Responses Module — Response wall, moderation, upvoting
- CampaignEmailsDrawer Component — Email statistics drawer
- Public Campaign Page — Public-facing campaign detail page
- Campaigns API Reference — Complete endpoint documentation
- Influence Feature Guide — End-to-end campaign workflow
- User Guide: Campaign Management — Step-by-step campaign creation guide