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:
parent
ae5a90d8d4
commit
c00b4432d7
@ -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={
|
||||
|
||||
@ -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 ? [
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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' },
|
||||
|
||||
226
admin/src/components/volunteer/dashboard/ActionCampaignCard.tsx
Normal file
226
admin/src/components/volunteer/dashboard/ActionCampaignCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
admin/src/components/volunteer/dashboard/ActivityCard.tsx
Normal file
40
admin/src/components/volunteer/dashboard/ActivityCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
admin/src/components/volunteer/dashboard/MyEventsCard.tsx
Normal file
68
admin/src/components/volunteer/dashboard/MyEventsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
admin/src/components/volunteer/dashboard/ProfileCard.tsx
Normal file
74
admin/src/components/volunteer/dashboard/ProfileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
admin/src/components/volunteer/dashboard/ReferralCard.tsx
Normal file
63
admin/src/components/volunteer/dashboard/ReferralCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
admin/src/components/volunteer/dashboard/ResourcesGrid.tsx
Normal file
139
admin/src/components/volunteer/dashboard/ResourcesGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
admin/src/components/volunteer/dashboard/TakeActionCard.tsx
Normal file
65
admin/src/components/volunteer/dashboard/TakeActionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
admin/src/components/volunteer/dashboard/TrainingList.tsx
Normal file
93
admin/src/components/volunteer/dashboard/TrainingList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
admin/src/components/volunteer/dashboard/types.ts
Normal file
110
admin/src/components/volunteer/dashboard/types.ts
Normal 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[];
|
||||
}
|
||||
649
admin/src/pages/influence/ActionCampaignEditorPage.tsx
Normal file
649
admin/src/pages/influence/ActionCampaignEditorPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
205
admin/src/pages/influence/ActionCampaignsPage.tsx
Normal file
205
admin/src/pages/influence/ActionCampaignsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
admin/src/pages/volunteer/VolunteerDashboardPage.tsx
Normal file
135
admin/src/pages/volunteer/VolunteerDashboardPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user