Restructure volunteer dashboard to FAFC two-column layout

Matches the For Alberta For Canada reference: profile + goal progress
on the left, take-action CTA + action steps on the right. Specifics:

- ProfileCard now embeds the referral link (copy button + code) below
  the stats row, eliminating the standalone ReferralCard from the grid.

- ActionCampaignCard slimmed to progress-only: title, description,
  progress bar, reward text, and a "Next: {step}" hint. The steps
  list is extracted into a new ActionStepsList component.

- ActionStepsList renders as a compact card with kind-prefixed labels
  (Email:, Sign:, RSVP:) and Take Action / Mark done buttons, matching
  the FAFC action list on the right column.

- Dashboard page uses lg=12/12 for the top two-column section. Falls
  back to full-width single column when no campaign and no featured
  event exist. Mobile stacks vertically as before. The 3-up middle
  row (trainings / my events / activity) and full-width resources grid
  remain unchanged.

Bunker Admin
This commit is contained in:
bunker-admin 2026-04-11 21:27:58 -06:00
parent 82db26fcef
commit 054902b9f9
4 changed files with 266 additions and 202 deletions

View File

@ -1,96 +1,19 @@
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';
import { Card, Progress, Typography, Space, Tag, Alert } from 'antd';
import { TrophyOutlined } from '@ant-design/icons';
import type { DashboardActionCampaign } 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);
export default function ActionCampaignCard({ campaign }: ActionCampaignCardProps) {
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);
}
};
const nextStep = [...campaign.steps]
.sort((a, b) => a.order - b.order)
.find((s) => !s.completed);
return (
<Card
@ -112,11 +35,6 @@ export default function ActionCampaignCard({ campaign, onRefresh }: ActionCampai
{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>
@ -142,84 +60,17 @@ export default function ActionCampaignCard({ campaign, onRefresh }: ActionCampai
/>
)}
<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>
);
}}
/>
{!campaign.rewardEarned && campaign.rewardText && (
<Typography.Paragraph style={{ marginBottom: 0, fontSize: 13 }}>
<TrophyOutlined /> <strong>Reward:</strong> {campaign.rewardText}
</Typography.Paragraph>
)}
{nextStep && !campaign.rewardEarned && (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
<strong>Next:</strong> {nextStep.label}
</Typography.Text>
)}
</Space>
</Card>
);

View File

@ -0,0 +1,187 @@
import { useState } from 'react';
import { Card, List, Button, Typography, Tag, Space, App } from 'antd';
import {
VideoCameraOutlined,
MailOutlined,
FileTextOutlined,
CalendarOutlined,
EnvironmentOutlined,
TeamOutlined,
LinkOutlined,
CheckSquareOutlined,
CheckCircleFilled,
RightOutlined,
UnorderedListOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type { DashboardActionCampaign, DashboardActionStep, ActionStepKind } from './types';
interface ActionStepsListProps {
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 />,
};
const KIND_LABELS: Record<ActionStepKind, string> = {
WATCH_VIDEO: 'Watch',
SUBMIT_INFLUENCE: 'Email',
SIGN_PETITION: 'Sign',
RSVP_EVENT: 'RSVP',
SIGNUP_SHIFT: 'Shift',
JOIN_CHALLENGE: 'Join',
VISIT_LINK: 'Visit',
CUSTOM: 'Action',
};
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 ActionStepsList({ campaign, onRefresh }: ActionStepsListProps) {
const navigate = useNavigate();
const { message } = App.useApp();
const [completingStepId, setCompletingStepId] = useState<string | null>(null);
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) return;
if (link.external) {
window.open(link.to, '_blank', 'noopener,noreferrer');
} else {
navigate(link.to);
}
};
const sortedSteps = [...campaign.steps].sort((a, b) => a.order - b.order);
return (
<Card
title={
<Space>
<UnorderedListOutlined />
<span>Actions</span>
</Space>
}
extra={
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{campaign.completedSteps} of {campaign.totalSteps}
</Typography.Text>
}
styles={{ body: { padding: 0 } }}
>
<List
dataSource={sortedSteps}
renderItem={(step) => {
const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK';
const canNavigate = resolveStepLink(step) !== null;
return (
<List.Item
style={{ padding: '10px 20px', opacity: step.completed ? 0.65 : 1 }}
actions={[
step.completed ? (
<Tag color="success" icon={<CheckCircleFilled />}>Completed</Tag>
) : isSelfReport ? (
<Space size="small">
{canNavigate && (
<Button size="small" onClick={() => handleNavigate(step)}>Open</Button>
)}
<Button
size="small"
type="primary"
loading={completingStepId === step.id}
onClick={() => handleSelfReport(step)}
>
Mark 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: 28,
height: 28,
borderRadius: '50%',
background: step.completed ? '#52c41a' : 'rgba(52,152,219,0.2)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 14,
color: step.completed ? '#fff' : 'rgba(255,255,255,0.85)',
}}
>
{step.completed ? <CheckCircleFilled /> : KIND_ICONS[step.kind]}
</div>
}
title={
<span style={{ fontSize: 13 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{KIND_LABELS[step.kind]}:
</Typography.Text>{' '}
{step.label}
</span>
}
/>
</List.Item>
);
}}
/>
</Card>
);
}

View File

@ -1,5 +1,5 @@
import { Card, Avatar, Typography, Row, Col, Statistic } from 'antd';
import { TeamOutlined, TrophyOutlined, CalendarOutlined } from '@ant-design/icons';
import { Card, Avatar, Typography, Row, Col, Statistic, Input, Button, App, Space } from 'antd';
import { TeamOutlined, TrophyOutlined, CalendarOutlined, CopyOutlined } from '@ant-design/icons';
import type { DashboardProfile, DashboardReferral, DashboardPoints, DashboardMyEvent } from './types';
interface ProfileCardProps {
@ -22,6 +22,7 @@ function getInitials(name: string | null, email: string): string {
}
export default function ProfileCard({ profile, referral, points, myEvents }: ProfileCardProps) {
const { message } = App.useApp();
const initials = getInitials(profile.name, profile.email);
const displayName = profile.name || profile.email.split('@')[0];
@ -69,6 +70,31 @@ export default function ProfileCard({ profile, referral, points, myEvents }: Pro
</Row>
</Col>
</Row>
{referral.link && (
<div style={{ marginTop: 16, paddingTop: 16, borderTop: '1px solid rgba(255,255,255,0.08)' }}>
<Typography.Text type="secondary" style={{ fontSize: 12, display: 'block', marginBottom: 8 }}>
Your Referral Link
</Typography.Text>
<Space.Compact style={{ width: '100%' }}>
<Input
value={referral.link}
readOnly
size="small"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<Button
size="small"
type="primary"
icon={<CopyOutlined />}
onClick={() => {
navigator.clipboard.writeText(referral.link!).then(() => message.success('Link copied'));
}}
>
Copy
</Button>
</Space.Compact>
</div>
)}
</Card>
);
}

View File

@ -1,11 +1,11 @@
import { useEffect, useState, useCallback } from 'react';
import { Row, Col, Skeleton, Result, Button, Typography, Card, Empty } from 'antd';
import { Row, Col, Skeleton, Result, Button, Typography, Card, Empty, Space } 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 ActionStepsList from '@/components/volunteer/dashboard/ActionStepsList';
import TakeActionCard from '@/components/volunteer/dashboard/TakeActionCard';
import TrainingList from '@/components/volunteer/dashboard/TrainingList';
import MyEventsCard from '@/components/volunteer/dashboard/MyEventsCard';
@ -43,20 +43,11 @@ export default function VolunteerDashboardPage() {
return (
<div>
<Row gutter={[16, 16]}>
<Col xs={24}>
<Card>
<Skeleton avatar paragraph={{ rows: 2 }} active />
</Card>
<Col xs={24} lg={12}>
<Card><Skeleton avatar paragraph={{ rows: 3 }} 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 xs={24} lg={12}>
<Card><Skeleton paragraph={{ rows: 4 }} active /></Card>
</Col>
</Row>
</div>
@ -86,6 +77,8 @@ export default function VolunteerDashboardPage() {
);
}
const hasRightColumn = !!data.featuredEvent || !!data.actionCampaign;
return (
<div>
<Typography.Title level={3} style={{ marginBottom: 16 }}>
@ -93,29 +86,35 @@ export default function VolunteerDashboardPage() {
</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} />
{/* ── Top section: 2 columns ── */}
<Col xs={24} lg={hasRightColumn ? 12 : 24}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<ProfileCard
profile={data.profile}
referral={data.referral}
points={data.points}
myEvents={data.myEvents}
/>
{data.actionCampaign && (
<ActionCampaignCard campaign={data.actionCampaign} />
)}
</Space>
</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} />
{hasRightColumn && (
<Col xs={24} lg={12}>
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{data.featuredEvent && (
<TakeActionCard event={data.featuredEvent} />
)}
{data.actionCampaign && (
<ActionStepsList campaign={data.actionCampaign} onRefresh={fetchDashboard} />
)}
</Space>
</Col>
)}
{/* ── Middle section: 3-up ── */}
<Col xs={24} md={8}>
<TrainingList trainings={data.trainings} onRefresh={fetchDashboard} />
</Col>
@ -126,6 +125,7 @@ export default function VolunteerDashboardPage() {
<ActivityCard points={data.points} />
</Col>
{/* ── Bottom: resources ── */}
<Col xs={24}>
<ResourcesGrid resources={data.resources} />
</Col>