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:
parent
82db26fcef
commit
054902b9f9
@ -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>
|
||||
);
|
||||
|
||||
187
admin/src/components/volunteer/dashboard/ActionStepsList.tsx
Normal file
187
admin/src/components/volunteer/dashboard/ActionStepsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user