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 ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
||||||
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
||||||
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
|
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 ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
||||||
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
||||||
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
||||||
@ -184,7 +187,7 @@ function RoleAwareRedirect() {
|
|||||||
|
|
||||||
function NavigateToCutMap() {
|
function NavigateToCutMap() {
|
||||||
const { cutId } = useParams<{ cutId: string }>();
|
const { cutId } = useParams<{ cutId: string }>();
|
||||||
return <Navigate to={`/volunteer?cutId=${cutId}`} replace />;
|
return <Navigate to={`/volunteer/map?cutId=${cutId}`} replace />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
@ -370,9 +373,9 @@ export default function App() {
|
|||||||
{/* Email link alias for video viewer */}
|
{/* Email link alias for video viewer */}
|
||||||
<Route path="/media/:id" element={<MediaViewerPage />} />
|
<Route path="/media/:id" element={<MediaViewerPage />} />
|
||||||
|
|
||||||
{/* Volunteer map — full-screen, default landing page */}
|
{/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */}
|
||||||
<Route
|
<Route
|
||||||
path="/volunteer"
|
path="/volunteer/map"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute>
|
<ProtectedRoute>
|
||||||
<VolunteerMapPage />
|
<VolunteerMapPage />
|
||||||
@ -398,6 +401,7 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
<Route path="/volunteer" element={<VolunteerDashboardPage />} />
|
||||||
<Route path="/volunteer/activity" element={<MyActivityPage />} />
|
<Route path="/volunteer/activity" element={<MyActivityPage />} />
|
||||||
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
|
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
|
||||||
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
|
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
|
||||||
@ -625,6 +629,30 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</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
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -187,6 +187,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
{ key: '/app/representatives', icon: <IdcardOutlined />, label: 'Representatives' },
|
{ 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/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/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/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
||||||
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
||||||
...(settings?.enablePetitions !== false ? [
|
...(settings?.enablePetitions !== false ? [
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useMemo } from 'react';
|
|||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { theme } from 'antd';
|
import { theme } from 'antd';
|
||||||
import {
|
import {
|
||||||
|
HomeOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
@ -15,7 +16,8 @@ import {
|
|||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
|
|
||||||
const BASE_NAV_ITEMS = [
|
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/shifts', icon: ScheduleOutlined, label: 'Shifts' },
|
||||||
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
|
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
|
||||||
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
|
{ key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' },
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
UserOutlined,
|
UserOutlined,
|
||||||
GlobalOutlined,
|
GlobalOutlined,
|
||||||
AppstoreOutlined,
|
AppstoreOutlined,
|
||||||
|
HomeOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
HistoryOutlined,
|
HistoryOutlined,
|
||||||
@ -49,7 +50,8 @@ export default function VolunteerLayout() {
|
|||||||
// Build nav items list (mirrors VolunteerFooterNav logic)
|
// Build nav items list (mirrors VolunteerFooterNav logic)
|
||||||
const navItems = useMemo(() => {
|
const navItems = useMemo(() => {
|
||||||
const items: { key: string; icon: React.ReactNode; label: string }[] = [
|
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/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
|
||||||
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
|
{ key: '/volunteer/activity', icon: <HistoryOutlined />, label: 'Activity' },
|
||||||
{ key: '/volunteer/routes', icon: <NodeIndexOutlined />, label: 'Routes' },
|
{ 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