Add volunteer dashboard page + ActionCampaigns admin editor

VolunteerDashboardPage replaces the old direct-to-map landing at
/volunteer with a personalized action hub modeled on the For Alberta
For Canada layout: profile + referral on top, action campaign goal
tile next to a featured event CTA, training shifts + my events +
points + resources. The map moves to /volunteer/map as a fullscreen
route outside VolunteerLayout. CutRedirect updated to match.

VolunteerFooterNav and VolunteerLayout drawer get Home/Map split
tabs. AppLayout sidebar gets an Action Campaigns link under the
Advocacy menu.

ActionCampaignsPage lists campaigns; ActionCampaignEditorPage edits
metadata + steps with type-aware target pickers per ActionStepKind
(video picker, petition picker, ticketed-event picker, etc).
CUSTOM/VISIT_LINK steps get a free-form target URL. Reorder via
up/down buttons.

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-11 10:21:10 -06:00
parent ae5a90d8d4
commit c00b4432d7
16 changed files with 1905 additions and 5 deletions

View File

@ -146,6 +146,9 @@ import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
import ActionCampaignsPage from '@/pages/influence/ActionCampaignsPage';
import ActionCampaignEditorPage from '@/pages/influence/ActionCampaignEditorPage';
import VolunteerDashboardPage from '@/pages/volunteer/VolunteerDashboardPage';
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
@ -184,7 +187,7 @@ function RoleAwareRedirect() {
function NavigateToCutMap() {
const { cutId } = useParams<{ cutId: string }>();
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />;
}
export default function App() {
@ -370,9 +373,9 @@ export default function App() {
{/* Email link alias for video viewer */}
<Route path="/media/:id" element={<MediaViewerPage />} />
{/* Volunteer map — full-screen, default landing page */}
{/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */}
<Route
path="/volunteer"
path="/volunteer/map"
element={
<ProtectedRoute>
<VolunteerMapPage />
@ -398,6 +401,7 @@ export default function App() {
</ProtectedRoute>
}
>
<Route path="/volunteer" element={<VolunteerDashboardPage />} />
<Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
@ -625,6 +629,30 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignsPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/new"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route
path="influence/action-campaigns/:id"
element={
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ActionCampaignEditorPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={

View File

@ -187,6 +187,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
{ key: '/app/email-queue', icon: <MailOutlined />, label: badges?.pendingEmails ? <Badge count={badges.pendingEmails} size="small" offset={[8, 0]}>Outgoing Emails</Badge> : 'Outgoing Emails' },
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/action-campaigns', icon: <TrophyOutlined />, label: 'Action Campaigns' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePetitions !== false ? [

View File

@ -2,6 +2,7 @@ import { useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd';
import {
HomeOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
@ -15,7 +16,8 @@ import {
import { useSettingsStore } from '@/stores/settings.store';
const BASE_NAV_ITEMS = [
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer', icon: HomeOutlined, label: 'Home' },
{ key: '/volunteer/map', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },

View File

@ -6,6 +6,7 @@ import {
UserOutlined,
GlobalOutlined,
AppstoreOutlined,
HomeOutlined,
EnvironmentOutlined,
ScheduleOutlined,
HistoryOutlined,
@ -49,7 +50,8 @@ export default function VolunteerLayout() {
// Build nav items list (mirrors VolunteerFooterNav logic)
const navItems = useMemo(() => {
const items: { key: string; icon: React.ReactNode; label: string }[] = [
{ key: '/volunteer', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer', icon: <HomeOutlined />, label: 'Home' },
{ key: '/volunteer/map', icon: <EnvironmentOutlined />, label: 'Map' },
{ key: '/volunteer/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },

View File

@ -0,0 +1,226 @@
import { useState } from 'react';
import { Card, Progress, List, Button, Typography, Space, Tag, Alert, App } from 'antd';
import {
VideoCameraOutlined,
MailOutlined,
FileTextOutlined,
CalendarOutlined,
EnvironmentOutlined,
TeamOutlined,
LinkOutlined,
CheckSquareOutlined,
CheckCircleFilled,
TrophyOutlined,
RightOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { DashboardActionCampaign, DashboardActionStep, ActionStepKind } from './types';
interface ActionCampaignCardProps {
campaign: DashboardActionCampaign;
onRefresh: () => void;
}
const KIND_ICONS: Record<ActionStepKind, React.ReactNode> = {
WATCH_VIDEO: <VideoCameraOutlined />,
SUBMIT_INFLUENCE: <MailOutlined />,
SIGN_PETITION: <FileTextOutlined />,
RSVP_EVENT: <CalendarOutlined />,
SIGNUP_SHIFT: <EnvironmentOutlined />,
JOIN_CHALLENGE: <TeamOutlined />,
VISIT_LINK: <LinkOutlined />,
CUSTOM: <CheckSquareOutlined />,
};
function resolveStepLink(step: DashboardActionStep): { to: string; external: boolean } | null {
if (step.targetUrl) {
const external = /^https?:\/\//i.test(step.targetUrl);
return { to: step.targetUrl, external };
}
if (!step.targetId) return null;
switch (step.kind) {
case 'WATCH_VIDEO':
return { to: `/gallery/watch/${step.targetId}`, external: false };
case 'SUBMIT_INFLUENCE':
return { to: `/campaign/${step.targetId}`, external: false };
case 'SIGN_PETITION':
return { to: `/petition/${step.targetId}`, external: false };
case 'RSVP_EVENT':
return { to: `/event/${step.targetId}`, external: false };
case 'SIGNUP_SHIFT':
return { to: `/volunteer/shifts?shiftId=${step.targetId}`, external: false };
case 'JOIN_CHALLENGE':
return { to: `/volunteer/challenges/${step.targetId}`, external: false };
default:
return null;
}
}
export default function ActionCampaignCard({ campaign, onRefresh }: ActionCampaignCardProps) {
const navigate = useNavigate();
const { message } = App.useApp();
const [completingStepId, setCompletingStepId] = useState<string | null>(null);
const percent = campaign.totalSteps > 0
? Math.round((campaign.completedSteps / campaign.totalSteps) * 100)
: 0;
const handleSelfReport = async (step: DashboardActionStep) => {
setCompletingStepId(step.id);
try {
await api.post(`/action-campaigns/${campaign.slug}/steps/${step.id}/complete`);
message.success('Step marked as done');
onRefresh();
} catch {
message.error('Failed to mark step as done');
} finally {
setCompletingStepId(null);
}
};
const handleNavigate = (step: DashboardActionStep) => {
const link = resolveStepLink(step);
if (!link) {
message.info('No link configured for this step');
return;
}
if (link.external) {
window.open(link.to, '_blank', 'noopener,noreferrer');
} else {
navigate(link.to);
}
};
return (
<Card
title={
<Space>
<TrophyOutlined />
<span>Your Goal</span>
</Space>
}
styles={{ body: { padding: 20 } }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<Typography.Title level={4} style={{ margin: 0 }}>
{campaign.title}
</Typography.Title>
{campaign.description && (
<Typography.Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0, fontSize: 13 }}>
{campaign.description}
</Typography.Paragraph>
)}
{campaign.rewardText && !campaign.rewardEarned && (
<Typography.Paragraph style={{ marginTop: 8, marginBottom: 0, fontSize: 13 }}>
<TrophyOutlined /> <strong>Reward:</strong> {campaign.rewardText}
</Typography.Paragraph>
)}
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Typography.Text style={{ fontSize: 13 }}>
Progress: {campaign.completedSteps} of {campaign.totalSteps} complete
</Typography.Text>
{campaign.rewardEarned && <Tag color="gold">Reward Earned!</Tag>}
</div>
<Progress
percent={percent}
status={campaign.rewardEarned ? 'success' : 'active'}
showInfo={false}
/>
</div>
{campaign.rewardEarned && campaign.rewardText && (
<Alert
type="success"
showIcon
message="You've earned your reward!"
description={campaign.rewardText}
/>
)}
<List
dataSource={[...campaign.steps].sort((a, b) => a.order - b.order)}
renderItem={(step) => {
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
const canNavigate = resolveStepLink(step) !== null;
return (
<List.Item
style={{
opacity: step.completed ? 0.7 : 1,
padding: '12px 0',
}}
actions={[
step.completed ? (
<Tag color="success" icon={<CheckCircleFilled />}>
Completed
</Tag>
) : isSelfReport ? (
<Space size="small">
{canNavigate && (
<Button
size="small"
icon={<RightOutlined />}
onClick={() => handleNavigate(step)}
>
Open
</Button>
)}
<Button
size="small"
type="primary"
loading={completingStepId === step.id}
onClick={() => handleSelfReport(step)}
>
Mark as done
</Button>
</Space>
) : (
<Button
size="small"
type="primary"
icon={<RightOutlined />}
onClick={() => handleNavigate(step)}
disabled={!canNavigate}
>
Take action
</Button>
),
]}
>
<List.Item.Meta
avatar={
<div
style={{
width: 32,
height: 32,
borderRadius: '50%',
background: step.completed ? '#52c41a' : 'rgba(52,152,219,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 16,
color: step.completed ? '#fff' : 'rgba(255,255,255,0.85)',
}}
>
{step.completed ? <CheckCircleFilled /> : KIND_ICONS[step.kind]}
</div>
}
title={
<span style={{ textDecoration: step.completed ? 'line-through' : 'none' }}>
{step.label}
</span>
}
description={step.description}
/>
</List.Item>
);
}}
/>
</Space>
</Card>
);
}

View File

@ -0,0 +1,40 @@
import { Card, Row, Col, Statistic, Space } from 'antd';
import { TrophyOutlined, StarFilled } from '@ant-design/icons';
import type { DashboardPoints } from './types';
interface ActivityCardProps {
points: DashboardPoints;
}
export default function ActivityCard({ points }: ActivityCardProps) {
return (
<Card
title={
<Space>
<TrophyOutlined />
<span>My Activity</span>
</Space>
}
styles={{ body: { padding: 20 } }}
>
<Row gutter={[16, 16]}>
<Col xs={12}>
<Statistic
title="Total Points"
value={points.total}
prefix={<TrophyOutlined style={{ color: '#faad14' }} />}
valueStyle={{ fontSize: 32, fontWeight: 600 }}
/>
</Col>
<Col xs={12}>
<Statistic
title="Achievements"
value={points.achievementCount}
prefix={<StarFilled style={{ color: '#f5222d' }} />}
valueStyle={{ fontSize: 32, fontWeight: 600 }}
/>
</Col>
</Row>
</Card>
);
}

View File

@ -0,0 +1,68 @@
import { Card, List, Button, Typography, Space, Empty, Tag } from 'antd';
import { CalendarOutlined, TagOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import type { DashboardMyEvent } from './types';
interface MyEventsCardProps {
events: DashboardMyEvent[];
}
export default function MyEventsCard({ events }: MyEventsCardProps) {
const navigate = useNavigate();
return (
<Card
title={
<Space>
<CalendarOutlined />
<span>My Events</span>
</Space>
}
styles={{ body: { padding: 0 } }}
>
{events.length === 0 ? (
<div style={{ padding: 24 }}>
<Empty
description="You haven't RSVPed to any events yet."
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
) : (
<List
dataSource={events}
renderItem={(event) => (
<List.Item
style={{ padding: '12px 20px' }}
actions={[
<Button
size="small"
type="link"
onClick={() => navigate(`/event/${event.eventSlug}`)}
>
Details
</Button>,
]}
>
<List.Item.Meta
title={event.eventTitle}
description={
<Space size={8} wrap>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{dayjs(event.eventDate).format('MMM D, YYYY')}
</Typography.Text>
{event.tierName && (
<Tag icon={<TagOutlined />} color="blue">
{event.tierName}
</Tag>
)}
</Space>
}
/>
</List.Item>
)}
/>
)}
</Card>
);
}

View File

@ -0,0 +1,74 @@
import { Card, Avatar, Typography, Row, Col, Statistic } from 'antd';
import { TeamOutlined, TrophyOutlined, CalendarOutlined } from '@ant-design/icons';
import type { DashboardProfile, DashboardReferral, DashboardPoints, DashboardMyEvent } from './types';
interface ProfileCardProps {
profile: DashboardProfile;
referral: DashboardReferral;
points: DashboardPoints;
myEvents: DashboardMyEvent[];
}
function getInitials(name: string | null, email: string): string {
if (name && name.trim()) {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return email.slice(0, 2).toUpperCase();
if (parts.length === 1) return (parts[0] || '').slice(0, 2).toUpperCase();
const first = parts[0] || '';
const last = parts[parts.length - 1] || '';
return ((first[0] || '') + (last[0] || '')).toUpperCase();
}
return email.slice(0, 2).toUpperCase();
}
export default function ProfileCard({ profile, referral, points, myEvents }: ProfileCardProps) {
const initials = getInitials(profile.name, profile.email);
const displayName = profile.name || profile.email.split('@')[0];
return (
<Card styles={{ body: { padding: 20 } }}>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={6} style={{ textAlign: 'center' }}>
{profile.avatar ? (
<Avatar size={80} src={profile.avatar} />
) : (
<Avatar size={80} style={{ backgroundColor: '#3498db', fontSize: 28, fontWeight: 600 }}>
{initials}
</Avatar>
)}
<Typography.Title level={4} style={{ margin: '12px 0 4px' }}>
{displayName}
</Typography.Title>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{profile.email}
</Typography.Text>
</Col>
<Col xs={24} sm={18}>
<Row gutter={[16, 16]}>
<Col xs={8}>
<Statistic
title="Referrals"
value={referral.totalReferrals}
prefix={<TeamOutlined />}
/>
</Col>
<Col xs={8}>
<Statistic
title="Points"
value={points.total}
prefix={<TrophyOutlined />}
/>
</Col>
<Col xs={8}>
<Statistic
title="Events"
value={myEvents.length}
prefix={<CalendarOutlined />}
/>
</Col>
</Row>
</Col>
</Row>
</Card>
);
}

View File

@ -0,0 +1,63 @@
import { Card, Input, Button, Typography, Space, App } from 'antd';
import { CopyOutlined, ShareAltOutlined } from '@ant-design/icons';
import type { DashboardReferral } from './types';
interface ReferralCardProps {
referral: DashboardReferral;
}
export default function ReferralCard({ referral }: ReferralCardProps) {
const { message } = App.useApp();
const handleCopyLink = () => {
navigator.clipboard.writeText(referral.link).then(() => {
message.success('Share link copied');
});
};
const handleCopyCode = () => {
navigator.clipboard.writeText(referral.code).then(() => {
message.success('Code copied');
});
};
return (
<Card
title={
<Space>
<ShareAltOutlined />
<span>Invite Friends</span>
</Space>
}
styles={{ body: { padding: 20 } }}
>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 0, fontSize: 13 }}>
Share your referral link with friends. You've referred <strong>{referral.totalReferrals}</strong> {referral.totalReferrals === 1 ? 'person' : 'people'} so far.
</Typography.Paragraph>
<Input.Group compact style={{ display: 'flex' }}>
<Input
value={referral.link}
readOnly
style={{ flex: 1 }}
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button type="primary" icon={<CopyOutlined />} onClick={handleCopyLink}>
Copy
</Button>
</Input.Group>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
Your code:
</Typography.Text>
<Typography.Text code strong style={{ fontSize: 14, letterSpacing: 1 }}>
{referral.code}
</Typography.Text>
<Button size="small" icon={<CopyOutlined />} onClick={handleCopyCode}>
Copy code
</Button>
</div>
</Space>
</Card>
);
}

View File

@ -0,0 +1,139 @@
import { Card, Row, Col, Button, Typography, Empty, Space } from 'antd';
import {
FileTextOutlined,
VideoCameraOutlined,
PictureOutlined,
DownloadOutlined,
EyeOutlined,
FolderOpenOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { mediaApi } from '@/lib/media-api';
import type { DashboardResource } from './types';
interface ResourcesGridProps {
resources: DashboardResource[];
}
const KIND_ICONS = {
document: <FileTextOutlined style={{ fontSize: 36 }} />,
video: <VideoCameraOutlined style={{ fontSize: 36 }} />,
photo: <PictureOutlined style={{ fontSize: 36 }} />,
};
const KIND_LABELS = {
document: 'Document',
video: 'Video',
photo: 'Photo',
};
function resolveDownloadUrl(downloadPath: string): string {
if (/^https?:\/\//i.test(downloadPath)) return downloadPath;
const base = (mediaApi.defaults.baseURL || '/media').replace(/\/$/, '');
return `${base}${downloadPath.startsWith('/') ? '' : '/'}${downloadPath}`;
}
export default function ResourcesGrid({ resources }: ResourcesGridProps) {
const navigate = useNavigate();
const handleAction = (resource: DashboardResource) => {
if (resource.kind === 'document' && resource.downloadPath) {
const url = resolveDownloadUrl(resource.downloadPath);
const link = document.createElement('a');
link.href = url;
link.download = resource.title;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
return;
}
if (resource.viewPath) {
if (/^https?:\/\//i.test(resource.viewPath)) {
window.open(resource.viewPath, '_blank', 'noopener,noreferrer');
} else {
navigate(resource.viewPath);
}
}
};
return (
<Card
title={
<Space>
<FolderOpenOutlined />
<span>Resources</span>
</Space>
}
styles={{ body: { padding: 20 } }}
>
{resources.length === 0 ? (
<Empty description="No resources available yet" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Row gutter={[16, 16]}>
{resources.map((resource) => (
<Col key={resource.id} xs={12} sm={8} md={6}>
<Card
hoverable
size="small"
styles={{ body: { padding: 12 } }}
style={{ height: '100%' }}
>
<div
style={{
height: 100,
marginBottom: 12,
background: 'rgba(255,255,255,0.05)',
borderRadius: 6,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
}}
>
{resource.thumbnailUrl ? (
<img
src={resource.thumbnailUrl}
alt={resource.title}
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
) : (
KIND_ICONS[resource.kind]
)}
</div>
<Typography.Text
strong
style={{
display: 'block',
fontSize: 13,
marginBottom: 4,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{resource.title}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 8 }}>
{KIND_LABELS[resource.kind]}
</Typography.Text>
<Button
size="small"
block
icon={resource.kind === 'document' ? <DownloadOutlined /> : <EyeOutlined />}
onClick={() => handleAction(resource)}
disabled={
resource.kind === 'document'
? !resource.downloadPath
: !resource.viewPath
}
>
{resource.kind === 'document' ? 'Download' : 'View'}
</Button>
</Card>
</Col>
))}
</Row>
)}
</Card>
);
}

View File

@ -0,0 +1,65 @@
import { Card, Button, Typography, Space } from 'antd';
import { FireOutlined, CalendarOutlined, EnvironmentOutlined, RightOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import type { DashboardFeaturedEvent } from './types';
interface TakeActionCardProps {
event: DashboardFeaturedEvent;
}
export default function TakeActionCard({ event }: TakeActionCardProps) {
const navigate = useNavigate();
const handleClick = () => {
navigate(`/event/${event.slug}`);
};
return (
<Card
title={
<Space>
<FireOutlined style={{ color: '#ff4d4f' }} />
<span>Take Action</span>
</Space>
}
styles={{ body: { padding: 0 } }}
style={{ overflow: 'hidden' }}
>
{event.coverImageUrl && (
<div
style={{
height: 140,
background: `url(${event.coverImageUrl}) center/cover`,
borderBottom: '1px solid rgba(255,255,255,0.1)',
}}
/>
)}
<div style={{ padding: 20 }}>
<Typography.Title level={4} style={{ margin: '0 0 8px' }}>
{event.title}
</Typography.Title>
<Space direction="vertical" size="small" style={{ width: '100%', marginBottom: 16 }}>
<Typography.Text type="secondary">
<CalendarOutlined /> {dayjs(event.date).format('MMM D, YYYY')} at {event.startTime}
</Typography.Text>
{event.venueName && (
<Typography.Text type="secondary">
<EnvironmentOutlined /> {event.venueName}
</Typography.Text>
)}
</Space>
<Button
type="primary"
danger
size="large"
block
icon={<RightOutlined />}
onClick={handleClick}
>
Take Action
</Button>
</div>
</Card>
);
}

View File

@ -0,0 +1,93 @@
import { useState } from 'react';
import { Card, List, Button, Typography, Tag, Space, Empty, App } from 'antd';
import { ReadOutlined, CalendarOutlined, ClockCircleOutlined, EnvironmentOutlined, CheckOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { DashboardTraining } from './types';
interface TrainingListProps {
trainings: DashboardTraining[];
onRefresh: () => void;
}
export default function TrainingList({ trainings, onRefresh }: TrainingListProps) {
const { message } = App.useApp();
const [signingUpId, setSigningUpId] = useState<string | null>(null);
const handleSignup = async (training: DashboardTraining) => {
setSigningUpId(training.id);
try {
await api.post(`/map/shifts/volunteer/${training.id}/signup`);
message.success('You are signed up');
onRefresh();
} catch {
message.error('Failed to sign up for training');
} finally {
setSigningUpId(null);
}
};
return (
<Card
title={
<Space>
<ReadOutlined />
<span>Upcoming Trainings</span>
</Space>
}
styles={{ body: { padding: 0 } }}
>
{trainings.length === 0 ? (
<div style={{ padding: 24 }}>
<Empty description="No upcoming trainings" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
) : (
<List
dataSource={trainings}
renderItem={(training) => {
const full = training.maxVolunteers > 0 && training.currentVolunteers >= training.maxVolunteers;
return (
<List.Item
style={{ padding: '12px 20px' }}
actions={[
training.isSignedUp ? (
<Tag color="success" icon={<CheckOutlined />}>Signed up</Tag>
) : (
<Button
size="small"
type="primary"
loading={signingUpId === training.id}
disabled={full}
onClick={() => handleSignup(training)}
>
{full ? 'Full' : 'RSVP'}
</Button>
),
]}
>
<List.Item.Meta
title={training.title}
description={
<Space direction="vertical" size={2}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<CalendarOutlined /> {dayjs(training.date).format('MMM D, YYYY')}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<ClockCircleOutlined /> {training.startTime} {training.endTime}
</Typography.Text>
{training.location && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
<EnvironmentOutlined /> {training.location}
</Typography.Text>
)}
</Space>
}
/>
</List.Item>
);
}}
/>
)}
</Card>
);
}

View File

@ -0,0 +1,110 @@
export type ActionStepKind =
| 'WATCH_VIDEO'
| 'SUBMIT_INFLUENCE'
| 'SIGN_PETITION'
| 'RSVP_EVENT'
| 'SIGNUP_SHIFT'
| 'JOIN_CHALLENGE'
| 'VISIT_LINK'
| 'CUSTOM';
export interface DashboardProfile {
id: string;
email: string;
name: string | null;
avatar: string | null;
role: string;
}
export interface DashboardReferral {
code: string;
link: string;
totalReferrals: number;
}
export interface DashboardActionStep {
id: string;
order: number;
kind: ActionStepKind;
label: string;
description: string | null;
targetId: string | null;
targetUrl: string | null;
autoComplete: boolean;
completed: boolean;
completedAt: string | null;
source: 'AUTO' | 'SELF_REPORTED' | null;
}
export interface DashboardActionCampaign {
id: string;
slug: string;
title: string;
description: string | null;
rewardText: string | null;
isActive: boolean;
startsAt: string | null;
endsAt: string | null;
minStepsForReward: number | null;
totalSteps: number;
completedSteps: number;
rewardEarned: boolean;
steps: DashboardActionStep[];
}
export interface DashboardFeaturedEvent {
slug: string;
title: string;
date: string;
startTime: string;
venueName: string | null;
coverImageUrl: string | null;
ticketsSold: number;
maxAttendees: number | null;
}
export interface DashboardTraining {
id: string;
title: string;
date: string;
startTime: string;
endTime: string;
location: string | null;
currentVolunteers: number;
maxVolunteers: number;
isSignedUp: boolean;
}
export interface DashboardMyEvent {
ticketId: string;
eventSlug: string;
eventTitle: string;
eventDate: string;
status: string;
tierName: string | null;
}
export interface DashboardPoints {
total: number;
achievementCount: number;
}
export interface DashboardResource {
id: string;
kind: 'document' | 'video' | 'photo';
title: string;
thumbnailUrl: string | null;
downloadPath: string | null;
viewPath: string | null;
}
export interface VolunteerDashboardResponse {
profile: DashboardProfile;
referral: DashboardReferral;
actionCampaign: DashboardActionCampaign | null;
featuredEvent: DashboardFeaturedEvent | null;
trainings: DashboardTraining[];
myEvents: DashboardMyEvent[];
points: DashboardPoints;
resources: DashboardResource[];
}

View File

@ -0,0 +1,649 @@
import { useEffect, useState, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Card, Form, Input, Switch, Button, Space, Typography, Row, Col, DatePicker,
InputNumber, Select, Spin, App, List, Popconfirm, Grid, Tag,
} from 'antd';
import {
ArrowLeftOutlined, SaveOutlined, PlusOutlined, DeleteOutlined,
ArrowUpOutlined, ArrowDownOutlined, VideoCameraOutlined, MailOutlined,
FileTextOutlined, CalendarOutlined, EnvironmentOutlined, TeamOutlined,
LinkOutlined, CheckSquareOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import { mediaApi } from '@/lib/media-api';
type ActionStepKind =
| 'WATCH_VIDEO'
| 'SUBMIT_INFLUENCE'
| 'SIGN_PETITION'
| 'RSVP_EVENT'
| 'SIGNUP_SHIFT'
| 'JOIN_CHALLENGE'
| 'VISIT_LINK'
| 'CUSTOM';
interface ActionStep {
id: string;
order: number;
kind: ActionStepKind;
label: string;
description: string | null;
targetId: string | null;
targetUrl: string | null;
autoComplete: boolean;
}
interface ActionCampaign {
id: string;
slug: string;
title: string;
description: string | null;
rewardText: string | null;
isActive: boolean;
startsAt: string | null;
endsAt: string | null;
minStepsForReward: number | null;
steps: ActionStep[];
}
const KIND_OPTIONS: { value: ActionStepKind; label: string; icon: React.ReactNode }[] = [
{ value: 'WATCH_VIDEO', label: 'Watch video', icon: <VideoCameraOutlined /> },
{ value: 'SUBMIT_INFLUENCE', label: 'Submit advocacy campaign', icon: <MailOutlined /> },
{ value: 'SIGN_PETITION', label: 'Sign petition', icon: <FileTextOutlined /> },
{ value: 'RSVP_EVENT', label: 'RSVP to event', icon: <CalendarOutlined /> },
{ value: 'SIGNUP_SHIFT', label: 'Sign up for shift', icon: <EnvironmentOutlined /> },
{ value: 'JOIN_CHALLENGE', label: 'Join challenge', icon: <TeamOutlined /> },
{ value: 'VISIT_LINK', label: 'Visit link', icon: <LinkOutlined /> },
{ value: 'CUSTOM', label: 'Custom (self-report)', icon: <CheckSquareOutlined /> },
];
interface PickerOption {
value: string;
label: string;
}
function slugify(str: string): string {
return str
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
export default function ActionCampaignEditorPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const isNew = !id || id === 'new';
const [form] = Form.useForm();
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [steps, setSteps] = useState<ActionStep[]>([]);
const [campaignId, setCampaignId] = useState<string | null>(isNew ? null : id || null);
const [slugTouched, setSlugTouched] = useState(!isNew);
const [videoOpts, setVideoOpts] = useState<PickerOption[]>([]);
const [influenceOpts, setInfluenceOpts] = useState<PickerOption[]>([]);
const [petitionOpts, setPetitionOpts] = useState<PickerOption[]>([]);
const [eventOpts, setEventOpts] = useState<PickerOption[]>([]);
const [shiftOpts, setShiftOpts] = useState<PickerOption[]>([]);
const [challengeOpts, setChallengeOpts] = useState<PickerOption[]>([]);
const loadCampaign = useCallback(async () => {
if (isNew) return;
setLoading(true);
try {
const { data } = await api.get<ActionCampaign>(`/admin/action-campaigns/${id}`);
form.setFieldsValue({
title: data.title,
slug: data.slug,
description: data.description,
rewardText: data.rewardText,
isActive: data.isActive,
minStepsForReward: data.minStepsForReward,
startsAt: data.startsAt ? dayjs(data.startsAt) : null,
endsAt: data.endsAt ? dayjs(data.endsAt) : null,
});
setSteps([...data.steps].sort((a, b) => a.order - b.order));
setCampaignId(data.id);
} catch {
message.error('Failed to load action campaign');
navigate('/app/influence/action-campaigns');
} finally {
setLoading(false);
}
}, [id, isNew, form, message, navigate]);
useEffect(() => {
loadCampaign();
}, [loadCampaign]);
useEffect(() => {
(async () => {
try {
const res = await mediaApi.get('/videos?limit=200');
const videos = res.data?.videos || [];
setVideoOpts(
videos.map((v: { id: number | string; title: string }) => ({
value: String(v.id),
label: v.title,
})),
);
} catch {
/* non-critical */
}
})();
(async () => {
try {
const { data } = await api.get('/influence/campaigns', { params: { limit: 200 } });
const items = data?.campaigns || data?.items || [];
setInfluenceOpts(
items.map((c: { slug: string; title: string }) => ({
value: c.slug,
label: c.title,
})),
);
} catch {
try {
const { data } = await api.get('/campaigns', { params: { limit: 200 } });
const items = data?.campaigns || [];
setInfluenceOpts(
items.map((c: { slug: string; title: string }) => ({
value: c.slug,
label: c.title,
})),
);
} catch {
/* non-critical */
}
}
})();
(async () => {
try {
const { data } = await api.get('/petitions', { params: { limit: 200 } });
const items = data?.petitions || [];
setPetitionOpts(
items.map((p: { slug: string; title: string }) => ({
value: p.slug,
label: p.title,
})),
);
} catch {
/* non-critical */
}
})();
(async () => {
try {
const { data } = await api.get('/ticketed-events/admin', { params: { limit: 200 } });
const items = data?.events || data?.items || [];
setEventOpts(
items.map((e: { slug: string; title: string }) => ({
value: e.slug,
label: e.title,
})),
);
} catch {
/* non-critical */
}
})();
(async () => {
try {
const { data } = await api.get('/map/shifts', { params: { limit: 200 } });
const items = data?.shifts || data?.items || [];
setShiftOpts(
items.map((s: { id: string; title: string; name?: string }) => ({
value: s.id,
label: s.title || s.name || s.id,
})),
);
} catch {
/* non-critical */
}
})();
(async () => {
try {
const { data } = await api.get('/social/challenges', { params: { limit: 200 } });
const items = data?.challenges || data?.items || [];
setChallengeOpts(
items.map((c: { id: string; title: string; name?: string }) => ({
value: c.id,
label: c.title || c.name || c.id,
})),
);
} catch {
/* non-critical */
}
})();
}, []);
const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!slugTouched && isNew) {
form.setFieldValue('slug', slugify(e.target.value));
}
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
const payload = {
title: values.title,
slug: values.slug,
description: values.description || null,
rewardText: values.rewardText || null,
isActive: values.isActive ?? false,
minStepsForReward: values.minStepsForReward ?? null,
startsAt: values.startsAt ? values.startsAt.toISOString() : null,
endsAt: values.endsAt ? values.endsAt.toISOString() : null,
};
if (isNew && !campaignId) {
const { data } = await api.post<ActionCampaign>('/admin/action-campaigns', payload);
message.success('Action campaign created');
setCampaignId(data.id);
navigate(`/app/influence/action-campaigns/${data.id}`, { replace: true });
} else if (campaignId) {
await api.put(`/admin/action-campaigns/${campaignId}`, payload);
message.success('Action campaign saved');
}
} catch (err) {
if ((err as { errorFields?: unknown }).errorFields) return;
message.error('Failed to save campaign');
} finally {
setSaving(false);
}
};
const handleAddStep = async () => {
if (!campaignId) {
message.warning('Save the campaign first before adding steps');
return;
}
try {
const { data } = await api.post<ActionStep>(
`/admin/action-campaigns/${campaignId}/steps`,
{
kind: 'CUSTOM',
label: 'New step',
description: null,
targetId: null,
targetUrl: null,
},
);
setSteps((prev) => [...prev, data]);
} catch {
message.error('Failed to add step');
}
};
const handleUpdateStep = async (step: ActionStep, patch: Partial<ActionStep>) => {
if (!campaignId) return;
const next = { ...step, ...patch };
setSteps((prev) => prev.map((s) => (s.id === step.id ? next : s)));
try {
await api.put(`/admin/action-campaigns/${campaignId}/steps/${step.id}`, {
kind: next.kind,
label: next.label,
description: next.description,
targetId: next.targetId,
targetUrl: next.targetUrl,
});
} catch {
message.error('Failed to update step');
setSteps((prev) => prev.map((s) => (s.id === step.id ? step : s)));
}
};
const handleDeleteStep = async (step: ActionStep) => {
if (!campaignId) return;
try {
await api.delete(`/admin/action-campaigns/${campaignId}/steps/${step.id}`);
setSteps((prev) => prev.filter((s) => s.id !== step.id));
} catch {
message.error('Failed to delete step');
}
};
const handleReorder = async (from: number, to: number) => {
if (!campaignId) return;
if (to < 0 || to >= steps.length) return;
if (from < 0 || from >= steps.length) return;
const next = [...steps];
const moved = next[from];
if (!moved) return;
next.splice(from, 1);
next.splice(to, 0, moved);
setSteps(next);
try {
await api.post(`/admin/action-campaigns/${campaignId}/steps/reorder`, {
stepIds: next.map((s) => s.id),
});
} catch {
message.error('Failed to reorder steps');
setSteps(steps);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/app/influence/action-campaigns')}>
Back
</Button>
<Typography.Title level={4} style={{ margin: 0 }}>
{isNew ? 'New Action Campaign' : 'Edit Action Campaign'}
</Typography.Title>
<div style={{ marginLeft: 'auto' }}>
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
Save
</Button>
</div>
</div>
<Row gutter={[16, 16]}>
<Col xs={24} lg={12}>
<Card title="Details">
<Form form={form} layout="vertical" initialValues={{ isActive: false }}>
<Form.Item
name="title"
label="Title"
rules={[{ required: true, message: 'Title is required' }]}
>
<Input placeholder="Complete 3 actions to enter the draw" onChange={handleTitleChange} />
</Form.Item>
<Form.Item
name="slug"
label="Slug"
rules={[
{ required: true, message: 'Slug is required' },
{ pattern: /^[a-z0-9-]+$/, message: 'Lowercase letters, numbers, and hyphens only' },
]}
>
<Input placeholder="complete-3-actions" onChange={() => setSlugTouched(true)} />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Describe what volunteers will do and why" />
</Form.Item>
<Form.Item name="rewardText" label="Reward text">
<Input placeholder="Entry into our $500 gift card draw" />
</Form.Item>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item name="startsAt" label="Starts at">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="endsAt" label="Ends at">
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="minStepsForReward"
label="Minimum steps for reward"
extra="Leave blank to require all steps"
>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="isActive" label="Active" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title="Steps"
extra={
<Button icon={<PlusOutlined />} onClick={handleAddStep} disabled={!campaignId}>
Add step
</Button>
}
>
{!campaignId && (
<Typography.Paragraph type="secondary">
Save the campaign first to start adding steps.
</Typography.Paragraph>
)}
{campaignId && steps.length === 0 && (
<Typography.Paragraph type="secondary">
No steps yet. Click "Add step" to add the first one.
</Typography.Paragraph>
)}
<List
dataSource={steps}
rowKey="id"
renderItem={(step, idx) => (
<List.Item style={{ display: 'block', padding: '12px 0' }}>
<StepEditor
step={step}
index={idx}
total={steps.length}
videoOpts={videoOpts}
influenceOpts={influenceOpts}
petitionOpts={petitionOpts}
eventOpts={eventOpts}
shiftOpts={shiftOpts}
challengeOpts={challengeOpts}
onChange={(patch) => handleUpdateStep(step, patch)}
onDelete={() => handleDeleteStep(step)}
onMoveUp={() => handleReorder(idx, idx - 1)}
onMoveDown={() => handleReorder(idx, idx + 1)}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
</div>
);
}
interface StepEditorProps {
step: ActionStep;
index: number;
total: number;
videoOpts: PickerOption[];
influenceOpts: PickerOption[];
petitionOpts: PickerOption[];
eventOpts: PickerOption[];
shiftOpts: PickerOption[];
challengeOpts: PickerOption[];
onChange: (patch: Partial<ActionStep>) => void;
onDelete: () => void;
onMoveUp: () => void;
onMoveDown: () => void;
}
function StepEditor({
step, index, total, videoOpts, influenceOpts, petitionOpts,
eventOpts, shiftOpts, challengeOpts, onChange, onDelete, onMoveUp, onMoveDown,
}: StepEditorProps) {
const [localLabel, setLocalLabel] = useState(step.label);
const [localDescription, setLocalDescription] = useState(step.description || '');
const [localTargetUrl, setLocalTargetUrl] = useState(step.targetUrl || '');
useEffect(() => {
setLocalLabel(step.label);
setLocalDescription(step.description || '');
setLocalTargetUrl(step.targetUrl || '');
}, [step.id, step.label, step.description, step.targetUrl]);
const renderPicker = () => {
switch (step.kind) {
case 'WATCH_VIDEO':
return (
<Select
showSearch
placeholder="Select video"
options={videoOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'SUBMIT_INFLUENCE':
return (
<Select
showSearch
placeholder="Select advocacy campaign"
options={influenceOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'SIGN_PETITION':
return (
<Select
showSearch
placeholder="Select petition"
options={petitionOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'RSVP_EVENT':
return (
<Select
showSearch
placeholder="Select event"
options={eventOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'SIGNUP_SHIFT':
return (
<Select
showSearch
placeholder="Select shift"
options={shiftOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'JOIN_CHALLENGE':
return (
<Select
showSearch
placeholder="Select challenge"
options={challengeOpts}
value={step.targetId || undefined}
onChange={(val) => onChange({ targetId: val })}
filterOption={(input, option) =>
(option?.label as string)?.toLowerCase().includes(input.toLowerCase())
}
style={{ width: '100%' }}
/>
);
case 'VISIT_LINK':
case 'CUSTOM':
return (
<Input
placeholder="https://example.com"
value={localTargetUrl}
onChange={(e) => setLocalTargetUrl(e.target.value)}
onBlur={() => onChange({ targetUrl: localTargetUrl || null })}
/>
);
default:
return null;
}
};
return (
<Card size="small" style={{ background: 'rgba(255,255,255,0.02)' }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
<Tag>{index + 1}</Tag>
<Select
value={step.kind}
onChange={(val) => onChange({ kind: val, targetId: null, targetUrl: null })}
style={{ width: 220 }}
options={KIND_OPTIONS.map((opt) => ({ value: opt.value, label: (<Space>{opt.icon}<span>{opt.label}</span></Space>) }))}
/>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<Button
size="small"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={onMoveUp}
/>
<Button
size="small"
icon={<ArrowDownOutlined />}
disabled={index === total - 1}
onClick={onMoveDown}
/>
<Popconfirm title="Delete this step?" onConfirm={onDelete}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</div>
</div>
<Input
placeholder="Step label"
value={localLabel}
onChange={(e) => setLocalLabel(e.target.value)}
onBlur={() => {
if (localLabel !== step.label) onChange({ label: localLabel });
}}
/>
<Input.TextArea
placeholder="Optional description"
rows={2}
value={localDescription}
onChange={(e) => setLocalDescription(e.target.value)}
onBlur={() => {
if (localDescription !== (step.description || '')) {
onChange({ description: localDescription || null });
}
}}
/>
{renderPicker()}
</Space>
</Card>
);
}

View File

@ -0,0 +1,205 @@
import { useState, useEffect, useCallback } from 'react';
import {
Card, Table, Button, Space, Tag, Typography, Popconfirm, App, Grid,
} from 'antd';
import {
PlusOutlined, ReloadOutlined, EditOutlined, DeleteOutlined,
PlayCircleOutlined, PauseCircleOutlined, TrophyOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
interface ActionCampaignRow {
id: string;
slug: string;
title: string;
description: string | null;
rewardText: string | null;
isActive: boolean;
startsAt: string | null;
endsAt: string | null;
createdAt: string;
_count?: { steps: number };
stepCount?: number;
}
export default function ActionCampaignsPage() {
const navigate = useNavigate();
const { message } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [campaigns, setCampaigns] = useState<ActionCampaignRow[]>([]);
const [loading, setLoading] = useState(false);
const fetchCampaigns = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<{ items: ActionCampaignRow[] }>('/admin/action-campaigns');
setCampaigns(data.items || []);
} catch {
message.error('Failed to load action campaigns');
} finally {
setLoading(false);
}
}, [message]);
useEffect(() => {
fetchCampaigns();
}, [fetchCampaigns]);
const handleToggleActive = async (row: ActionCampaignRow) => {
try {
if (row.isActive) {
await api.put(`/admin/action-campaigns/${row.id}`, { isActive: false });
message.success('Campaign deactivated');
} else {
await api.post(`/admin/action-campaigns/${row.id}/activate`);
message.success('Campaign activated');
}
fetchCampaigns();
} catch {
message.error('Failed to update campaign');
}
};
const handleDelete = async (row: ActionCampaignRow) => {
try {
await api.delete(`/admin/action-campaigns/${row.id}`);
message.success('Campaign deleted');
fetchCampaigns();
} catch {
message.error('Failed to delete campaign');
}
};
const columns: ColumnsType<ActionCampaignRow> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, row) => (
<div>
<Typography.Text strong>{title}</Typography.Text>
{row.description && (
<div>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.description}
</Typography.Text>
</div>
)}
</div>
),
},
{
title: 'Slug',
dataIndex: 'slug',
key: 'slug',
responsive: ['md'],
render: (slug: string) => <Typography.Text code>{slug}</Typography.Text>,
},
{
title: 'Status',
key: 'status',
render: (_, row) =>
row.isActive ? (
<Tag color="green">Active</Tag>
) : (
<Tag color="default">Inactive</Tag>
),
},
{
title: 'Steps',
key: 'stepCount',
render: (_, row) => row.stepCount ?? row._count?.steps ?? 0,
},
{
title: 'Reward',
dataIndex: 'rewardText',
key: 'rewardText',
responsive: ['lg'],
render: (reward: string | null) =>
reward ? (
<Space>
<TrophyOutlined style={{ color: '#faad14' }} />
<Typography.Text>{reward}</Typography.Text>
</Space>
) : (
<Typography.Text type="secondary">None</Typography.Text>
),
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
responsive: ['lg'],
render: (createdAt: string) => dayjs(createdAt).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
render: (_, row) => (
<Space size="small" wrap>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => navigate(`/app/influence/action-campaigns/${row.id}`)}
>
Edit
</Button>
<Button
size="small"
icon={row.isActive ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
onClick={() => handleToggleActive(row)}
>
{row.isActive ? 'Deactivate' : 'Activate'}
</Button>
<Popconfirm
title="Delete this action campaign?"
description="This cannot be undone."
onConfirm={() => handleDelete(row)}
okText="Delete"
okButtonProps={{ danger: true }}
>
<Button size="small" danger icon={<DeleteOutlined />}>
Delete
</Button>
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: isMobile ? 16 : 24 }}>
<Card
title="Action Campaigns"
extra={
<Space>
<Button icon={<ReloadOutlined />} onClick={fetchCampaigns} loading={loading}>
Refresh
</Button>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => navigate('/app/influence/action-campaigns/new')}
>
New Action Campaign
</Button>
</Space>
}
>
<Table
rowKey="id"
dataSource={campaigns}
columns={columns}
loading={loading}
pagination={{ pageSize: 20 }}
scroll={{ x: 'max-content' }}
locale={{ emptyText: 'No action campaigns yet. Create one to get started.' }}
/>
</Card>
</div>
);
}

View File

@ -0,0 +1,135 @@
import { useEffect, useState, useCallback } from 'react';
import { Row, Col, Skeleton, Result, Button, Typography, Card, Empty } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import axios from 'axios';
import { api } from '@/lib/api';
import ProfileCard from '@/components/volunteer/dashboard/ProfileCard';
import ReferralCard from '@/components/volunteer/dashboard/ReferralCard';
import ActionCampaignCard from '@/components/volunteer/dashboard/ActionCampaignCard';
import TakeActionCard from '@/components/volunteer/dashboard/TakeActionCard';
import TrainingList from '@/components/volunteer/dashboard/TrainingList';
import MyEventsCard from '@/components/volunteer/dashboard/MyEventsCard';
import ActivityCard from '@/components/volunteer/dashboard/ActivityCard';
import ResourcesGrid from '@/components/volunteer/dashboard/ResourcesGrid';
import type { VolunteerDashboardResponse } from '@/components/volunteer/dashboard/types';
export default function VolunteerDashboardPage() {
const [data, setData] = useState<VolunteerDashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchDashboard = useCallback(async () => {
setError(null);
try {
const res = await api.get<VolunteerDashboardResponse>('/volunteer/dashboard');
setData(res.data);
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 404) {
setData(null);
setError('unavailable');
} else {
setError('failed');
}
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchDashboard();
}, [fetchDashboard]);
if (loading) {
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card>
<Skeleton avatar paragraph={{ rows: 2 }} active />
</Card>
</Col>
<Col xs={24} md={12}>
<Card>
<Skeleton paragraph={{ rows: 4 }} active />
</Card>
</Col>
<Col xs={24} md={12}>
<Card>
<Skeleton paragraph={{ rows: 4 }} active />
</Card>
</Col>
</Row>
</div>
);
}
if (error === 'failed') {
return (
<Result
status="error"
title="Failed to load dashboard"
subTitle="Something went wrong while loading your dashboard. Please try again."
extra={
<Button type="primary" icon={<ReloadOutlined />} onClick={fetchDashboard}>
Retry
</Button>
}
/>
);
}
if (!data) {
return (
<Card>
<Empty description="Your dashboard is not yet available. Please check back soon." />
</Card>
);
}
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>
Welcome back{data.profile.name ? `, ${data.profile.name.split(' ')[0]}` : ''}
</Typography.Title>
<Row gutter={[16, 16]}>
<Col xs={24} md={16}>
<ProfileCard
profile={data.profile}
referral={data.referral}
points={data.points}
myEvents={data.myEvents}
/>
</Col>
<Col xs={24} md={8}>
<ReferralCard referral={data.referral} />
</Col>
{data.actionCampaign && (
<Col xs={24} md={data.featuredEvent ? 14 : 24}>
<ActionCampaignCard campaign={data.actionCampaign} onRefresh={fetchDashboard} />
</Col>
)}
{data.featuredEvent && (
<Col xs={24} md={data.actionCampaign ? 10 : 24}>
<TakeActionCard event={data.featuredEvent} />
</Col>
)}
<Col xs={24} md={8}>
<TrainingList trainings={data.trainings} onRefresh={fetchDashboard} />
</Col>
<Col xs={24} md={8}>
<MyEventsCard events={data.myEvents} />
</Col>
<Col xs={24} md={8}>
<ActivityCard points={data.points} />
</Col>
<Col xs={24}>
<ResourcesGrid resources={data.resources} />
</Col>
</Row>
</div>
);
}