diff --git a/admin/src/App.tsx b/admin/src/App.tsx index dda59751..19874f84 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -146,6 +146,9 @@ import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; import StrawPollsPage from '@/pages/influence/StrawPollsPage'; +import ActionCampaignsPage from '@/pages/influence/ActionCampaignsPage'; +import ActionCampaignEditorPage from '@/pages/influence/ActionCampaignEditorPage'; +import VolunteerDashboardPage from '@/pages/volunteer/VolunteerDashboardPage'; import ReferralsPage from '@/pages/volunteer/ReferralsPage'; import ChallengesPage from '@/pages/volunteer/ChallengesPage'; import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; @@ -184,7 +187,7 @@ function RoleAwareRedirect() { function NavigateToCutMap() { const { cutId } = useParams<{ cutId: string }>(); - return ; + return ; } export default function App() { @@ -370,9 +373,9 @@ export default function App() { {/* Email link alias for video viewer */} } /> - {/* Volunteer map — full-screen, default landing page */} + {/* Volunteer map — full-screen (moved from /volunteer to /volunteer/map) */} @@ -398,6 +401,7 @@ export default function App() { } > + } /> } /> } /> } /> @@ -625,6 +629,30 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> , label: 'Representatives' }, { key: '/app/email-queue', icon: , label: badges?.pendingEmails ? Outgoing Emails : 'Outgoing Emails' }, { key: '/app/responses', icon: , label: badges?.pendingResponses ? Responses : 'Responses' }, + { key: '/app/influence/action-campaigns', icon: , label: 'Action Campaigns' }, { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, { key: '/app/influence/stories', icon: , label: 'Impact Stories' }, ...(settings?.enablePetitions !== false ? [ diff --git a/admin/src/components/VolunteerFooterNav.tsx b/admin/src/components/VolunteerFooterNav.tsx index d11dc612..01a9fb57 100644 --- a/admin/src/components/VolunteerFooterNav.tsx +++ b/admin/src/components/VolunteerFooterNav.tsx @@ -2,6 +2,7 @@ import { useMemo } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import { theme } from 'antd'; import { + HomeOutlined, EnvironmentOutlined, ScheduleOutlined, HistoryOutlined, @@ -15,7 +16,8 @@ import { import { useSettingsStore } from '@/stores/settings.store'; const BASE_NAV_ITEMS = [ - { key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' }, + { key: '/volunteer', icon: HomeOutlined, label: 'Home' }, + { key: '/volunteer/map', icon: EnvironmentOutlined, label: 'Map' }, { key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' }, { key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' }, { key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' }, diff --git a/admin/src/components/VolunteerLayout.tsx b/admin/src/components/VolunteerLayout.tsx index 843a936a..fc969159 100644 --- a/admin/src/components/VolunteerLayout.tsx +++ b/admin/src/components/VolunteerLayout.tsx @@ -6,6 +6,7 @@ import { UserOutlined, GlobalOutlined, AppstoreOutlined, + HomeOutlined, EnvironmentOutlined, ScheduleOutlined, HistoryOutlined, @@ -49,7 +50,8 @@ export default function VolunteerLayout() { // Build nav items list (mirrors VolunteerFooterNav logic) const navItems = useMemo(() => { const items: { key: string; icon: React.ReactNode; label: string }[] = [ - { key: '/volunteer', icon: , label: 'Map' }, + { key: '/volunteer', icon: , label: 'Home' }, + { key: '/volunteer/map', icon: , label: 'Map' }, { key: '/volunteer/shifts', icon: , label: 'Shifts' }, { key: '/volunteer/activity', icon: , label: 'Activity' }, { key: '/volunteer/routes', icon: , label: 'Routes' }, diff --git a/admin/src/components/volunteer/dashboard/ActionCampaignCard.tsx b/admin/src/components/volunteer/dashboard/ActionCampaignCard.tsx new file mode 100644 index 00000000..8767c8a8 --- /dev/null +++ b/admin/src/components/volunteer/dashboard/ActionCampaignCard.tsx @@ -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 = { + WATCH_VIDEO: , + SUBMIT_INFLUENCE: , + SIGN_PETITION: , + RSVP_EVENT: , + SIGNUP_SHIFT: , + JOIN_CHALLENGE: , + VISIT_LINK: , + CUSTOM: , +}; + +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(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 ( + + + Your Goal + + } + styles={{ body: { padding: 20 } }} + > + +
+ + {campaign.title} + + {campaign.description && ( + + {campaign.description} + + )} + {campaign.rewardText && !campaign.rewardEarned && ( + + Reward: {campaign.rewardText} + + )} +
+ +
+
+ + Progress: {campaign.completedSteps} of {campaign.totalSteps} complete + + {campaign.rewardEarned && Reward Earned!} +
+ +
+ + {campaign.rewardEarned && campaign.rewardText && ( + + )} + + a.order - b.order)} + renderItem={(step) => { + const isSelfReport = step.kind === 'CUSTOM' || step.kind === 'VISIT_LINK'; + const canNavigate = resolveStepLink(step) !== null; + return ( + }> + Completed + + ) : isSelfReport ? ( + + {canNavigate && ( + + )} + + + ) : ( + + ), + ]} + > + + {step.completed ? : KIND_ICONS[step.kind]} + + } + title={ + + {step.label} + + } + description={step.description} + /> + + ); + }} + /> +
+
+ ); +} diff --git a/admin/src/components/volunteer/dashboard/ActivityCard.tsx b/admin/src/components/volunteer/dashboard/ActivityCard.tsx new file mode 100644 index 00000000..172762b5 --- /dev/null +++ b/admin/src/components/volunteer/dashboard/ActivityCard.tsx @@ -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 ( + + + My Activity + + } + styles={{ body: { padding: 20 } }} + > + + + } + valueStyle={{ fontSize: 32, fontWeight: 600 }} + /> + + + } + valueStyle={{ fontSize: 32, fontWeight: 600 }} + /> + + + + ); +} diff --git a/admin/src/components/volunteer/dashboard/MyEventsCard.tsx b/admin/src/components/volunteer/dashboard/MyEventsCard.tsx new file mode 100644 index 00000000..8d4f5c7b --- /dev/null +++ b/admin/src/components/volunteer/dashboard/MyEventsCard.tsx @@ -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 ( + + + My Events + + } + styles={{ body: { padding: 0 } }} + > + {events.length === 0 ? ( +
+ +
+ ) : ( + ( + navigate(`/event/${event.eventSlug}`)} + > + Details + , + ]} + > + + + {dayjs(event.eventDate).format('MMM D, YYYY')} + + {event.tierName && ( + } color="blue"> + {event.tierName} + + )} + + } + /> + + )} + /> + )} +
+ ); +} diff --git a/admin/src/components/volunteer/dashboard/ProfileCard.tsx b/admin/src/components/volunteer/dashboard/ProfileCard.tsx new file mode 100644 index 00000000..10de5a8c --- /dev/null +++ b/admin/src/components/volunteer/dashboard/ProfileCard.tsx @@ -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 ( + + + + {profile.avatar ? ( + + ) : ( + + {initials} + + )} + + {displayName} + + + {profile.email} + + + + + + } + /> + + + } + /> + + + } + /> + + + + + + ); +} diff --git a/admin/src/components/volunteer/dashboard/ReferralCard.tsx b/admin/src/components/volunteer/dashboard/ReferralCard.tsx new file mode 100644 index 00000000..7ddc8545 --- /dev/null +++ b/admin/src/components/volunteer/dashboard/ReferralCard.tsx @@ -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 ( + + + Invite Friends + + } + styles={{ body: { padding: 20 } }} + > + + + Share your referral link with friends. You've referred {referral.totalReferrals} {referral.totalReferrals === 1 ? 'person' : 'people'} so far. + + + (e.target as HTMLInputElement).select()} + /> + + +
+ + Your code: + + + {referral.code} + + +
+
+
+ ); +} diff --git a/admin/src/components/volunteer/dashboard/ResourcesGrid.tsx b/admin/src/components/volunteer/dashboard/ResourcesGrid.tsx new file mode 100644 index 00000000..5ebb0427 --- /dev/null +++ b/admin/src/components/volunteer/dashboard/ResourcesGrid.tsx @@ -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: , + video: , + photo: , +}; + +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 ( + + + Resources + + } + styles={{ body: { padding: 20 } }} + > + {resources.length === 0 ? ( + + ) : ( + + {resources.map((resource) => ( + + +
+ {resource.thumbnailUrl ? ( + {resource.title} + ) : ( + KIND_ICONS[resource.kind] + )} +
+ + {resource.title} + + + {KIND_LABELS[resource.kind]} + + +
+ + ))} +
+ )} +
+ ); +} diff --git a/admin/src/components/volunteer/dashboard/TakeActionCard.tsx b/admin/src/components/volunteer/dashboard/TakeActionCard.tsx new file mode 100644 index 00000000..a2a0cdbb --- /dev/null +++ b/admin/src/components/volunteer/dashboard/TakeActionCard.tsx @@ -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 ( + + + Take Action + + } + styles={{ body: { padding: 0 } }} + style={{ overflow: 'hidden' }} + > + {event.coverImageUrl && ( +
+ )} +
+ + {event.title} + + + + {dayjs(event.date).format('MMM D, YYYY')} at {event.startTime} + + {event.venueName && ( + + {event.venueName} + + )} + + +
+ + ); +} diff --git a/admin/src/components/volunteer/dashboard/TrainingList.tsx b/admin/src/components/volunteer/dashboard/TrainingList.tsx new file mode 100644 index 00000000..cca44e6a --- /dev/null +++ b/admin/src/components/volunteer/dashboard/TrainingList.tsx @@ -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(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 ( + + + Upcoming Trainings + + } + styles={{ body: { padding: 0 } }} + > + {trainings.length === 0 ? ( +
+ +
+ ) : ( + { + const full = training.maxVolunteers > 0 && training.currentVolunteers >= training.maxVolunteers; + return ( + }>Signed up + ) : ( + + ), + ]} + > + + + {dayjs(training.date).format('MMM D, YYYY')} + + + {training.startTime} – {training.endTime} + + {training.location && ( + + {training.location} + + )} + + } + /> + + ); + }} + /> + )} +
+ ); +} diff --git a/admin/src/components/volunteer/dashboard/types.ts b/admin/src/components/volunteer/dashboard/types.ts new file mode 100644 index 00000000..5c171359 --- /dev/null +++ b/admin/src/components/volunteer/dashboard/types.ts @@ -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[]; +} diff --git a/admin/src/pages/influence/ActionCampaignEditorPage.tsx b/admin/src/pages/influence/ActionCampaignEditorPage.tsx new file mode 100644 index 00000000..23b0dd4b --- /dev/null +++ b/admin/src/pages/influence/ActionCampaignEditorPage.tsx @@ -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: }, + { value: 'SUBMIT_INFLUENCE', label: 'Submit advocacy campaign', icon: }, + { value: 'SIGN_PETITION', label: 'Sign petition', icon: }, + { value: 'RSVP_EVENT', label: 'RSVP to event', icon: }, + { value: 'SIGNUP_SHIFT', label: 'Sign up for shift', icon: }, + { value: 'JOIN_CHALLENGE', label: 'Join challenge', icon: }, + { value: 'VISIT_LINK', label: 'Visit link', icon: }, + { value: 'CUSTOM', label: 'Custom (self-report)', icon: }, +]; + +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([]); + const [campaignId, setCampaignId] = useState(isNew ? null : id || null); + const [slugTouched, setSlugTouched] = useState(!isNew); + + const [videoOpts, setVideoOpts] = useState([]); + const [influenceOpts, setInfluenceOpts] = useState([]); + const [petitionOpts, setPetitionOpts] = useState([]); + const [eventOpts, setEventOpts] = useState([]); + const [shiftOpts, setShiftOpts] = useState([]); + const [challengeOpts, setChallengeOpts] = useState([]); + + const loadCampaign = useCallback(async () => { + if (isNew) return; + setLoading(true); + try { + const { data } = await api.get(`/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) => { + 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('/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( + `/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) => { + 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 ( +
+ +
+ ); + } + + return ( +
+
+ + + {isNew ? 'New Action Campaign' : 'Edit Action Campaign'} + +
+ +
+
+ + + + +
+ + + + + setSlugTouched(true)} /> + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + } onClick={handleAddStep} disabled={!campaignId}> + Add step + + } + > + {!campaignId && ( + + Save the campaign first to start adding steps. + + )} + {campaignId && steps.length === 0 && ( + + No steps yet. Click "Add step" to add the first one. + + )} + ( + + handleUpdateStep(step, patch)} + onDelete={() => handleDeleteStep(step)} + onMoveUp={() => handleReorder(idx, idx - 1)} + onMoveDown={() => handleReorder(idx, idx + 1)} + /> + + )} + /> + + +
+
+ ); +} + +interface StepEditorProps { + step: ActionStep; + index: number; + total: number; + videoOpts: PickerOption[]; + influenceOpts: PickerOption[]; + petitionOpts: PickerOption[]; + eventOpts: PickerOption[]; + shiftOpts: PickerOption[]; + challengeOpts: PickerOption[]; + onChange: (patch: Partial) => 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 ( + onChange({ targetId: val })} + filterOption={(input, option) => + (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + style={{ width: '100%' }} + /> + ); + case 'SIGN_PETITION': + return ( + onChange({ targetId: val })} + filterOption={(input, option) => + (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + style={{ width: '100%' }} + /> + ); + case 'SIGNUP_SHIFT': + return ( + onChange({ targetId: val })} + filterOption={(input, option) => + (option?.label as string)?.toLowerCase().includes(input.toLowerCase()) + } + style={{ width: '100%' }} + /> + ); + case 'VISIT_LINK': + case 'CUSTOM': + return ( + setLocalTargetUrl(e.target.value)} + onBlur={() => onChange({ targetUrl: localTargetUrl || null })} + /> + ); + default: + return null; + } + }; + + return ( + + +
+ {index + 1} + setLocalLabel(e.target.value)} + onBlur={() => { + if (localLabel !== step.label) onChange({ label: localLabel }); + }} + /> + + setLocalDescription(e.target.value)} + onBlur={() => { + if (localDescription !== (step.description || '')) { + onChange({ description: localDescription || null }); + } + }} + /> + + {renderPicker()} + + + ); +} diff --git a/admin/src/pages/influence/ActionCampaignsPage.tsx b/admin/src/pages/influence/ActionCampaignsPage.tsx new file mode 100644 index 00000000..0dd7571c --- /dev/null +++ b/admin/src/pages/influence/ActionCampaignsPage.tsx @@ -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([]); + 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 = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + render: (title: string, row) => ( +
+ {title} + {row.description && ( +
+ + {row.description} + +
+ )} +
+ ), + }, + { + title: 'Slug', + dataIndex: 'slug', + key: 'slug', + responsive: ['md'], + render: (slug: string) => {slug}, + }, + { + title: 'Status', + key: 'status', + render: (_, row) => + row.isActive ? ( + Active + ) : ( + Inactive + ), + }, + { + 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 ? ( + + + {reward} + + ) : ( + None + ), + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + responsive: ['lg'], + render: (createdAt: string) => dayjs(createdAt).format('MMM D, YYYY'), + }, + { + title: 'Actions', + key: 'actions', + render: (_, row) => ( + + + + handleDelete(row)} + okText="Delete" + okButtonProps={{ danger: true }} + > + + + + ), + }, + ]; + + return ( +
+ + + + + } + > + + + + ); +} diff --git a/admin/src/pages/volunteer/VolunteerDashboardPage.tsx b/admin/src/pages/volunteer/VolunteerDashboardPage.tsx new file mode 100644 index 00000000..c67ae4ee --- /dev/null +++ b/admin/src/pages/volunteer/VolunteerDashboardPage.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDashboard = useCallback(async () => { + setError(null); + try { + const res = await api.get('/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 ( +
+ +
+ + + + + + + + + + + + + + + + + ); + } + + if (error === 'failed') { + return ( + } onClick={fetchDashboard}> + Retry + + } + /> + ); + } + + if (!data) { + return ( + + + + ); + } + + return ( +
+ + Welcome back{data.profile.name ? `, ${data.profile.name.split(' ')[0]}` : ''} + + + +
+ + + + + + + {data.actionCampaign && ( + + + + )} + {data.featuredEvent && ( + + + + )} + + + + + + + + + + + + + + + + + ); +}