- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout, QR-based check-in scanner, public event pages, ticket confirmation emails - Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle, ticket-gated meeting access, moderator JWT tokens, feature-flag guarded - Social engagement: challenges with scoring/leaderboards, referral tracking, volunteer spotlight, impact stories, campaign celebrations, wall of fame - Social calendar: personal calendar layers, shared calendar items with recurrence, scheduling polls, mobile day view - MCP server: events tool pack with full admin CRUD + meeting token generation - Unified calendar: eventFormat-aware tags, online event indicators - Updated docs site, pangolin configs, and various admin UI improvements Bunker Admin
736 lines
28 KiB
TypeScript
736 lines
28 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useParams, Link, useSearchParams } from 'react-router-dom';
|
|
import {
|
|
Typography,
|
|
Input,
|
|
Button,
|
|
Card,
|
|
Space,
|
|
Tag,
|
|
Avatar,
|
|
message,
|
|
Spin,
|
|
Result,
|
|
Grid,
|
|
Tooltip,
|
|
theme,
|
|
} from 'antd';
|
|
import {
|
|
SearchOutlined,
|
|
SendOutlined,
|
|
MailOutlined,
|
|
UserOutlined,
|
|
MessageOutlined,
|
|
CopyOutlined,
|
|
CheckCircleOutlined,
|
|
ArrowRightOutlined,
|
|
RocketOutlined,
|
|
ShareAltOutlined,
|
|
ScheduleOutlined,
|
|
PlayCircleOutlined,
|
|
} from '@ant-design/icons';
|
|
import axios from 'axios';
|
|
import { useAuthStore } from '@/stores/auth.store';
|
|
import { useSettingsStore } from '@/stores/settings.store';
|
|
import { usePageAds } from '@/hooks/usePageAds';
|
|
import AdBanner from '@/components/AdBanner';
|
|
import type {
|
|
Campaign,
|
|
Representative,
|
|
RepresentativeLookupResponse,
|
|
} from '@/types/api';
|
|
import { GOVERNMENT_LEVEL_COLORS, GOVERNMENT_LEVEL_LABELS } from '@/types/api';
|
|
import { mapRepSetToLevel } from '@/utils/representatives';
|
|
import { usePostalCode } from '@/hooks/usePostalCode';
|
|
import RelatedContent from '@/components/public/RelatedContent';
|
|
import { VideoPlayer } from '@/components/media/VideoPlayer';
|
|
import FriendsCampaignBadge from '@/components/social/FriendsCampaignBadge';
|
|
import CampaignCelebration from '@/components/social/CampaignCelebration';
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
|
|
const apiBase = '/api';
|
|
|
|
type Step = 'info' | 'reps' | 'send';
|
|
|
|
export default function CampaignPage() {
|
|
const { slug } = useParams<{ slug: string }>();
|
|
const [searchParams] = useSearchParams();
|
|
const screens = Grid.useBreakpoint();
|
|
const isMobile = !screens.md;
|
|
const { token } = theme.useToken();
|
|
const { isAuthenticated } = useAuthStore();
|
|
const { settings: siteSettings } = useSettingsStore();
|
|
const campaignAds = usePageAds('campaign_detail');
|
|
|
|
const [campaign, setCampaign] = useState<Campaign | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// Step tracking
|
|
const [currentStep, setCurrentStep] = useState<Step>('info');
|
|
|
|
// Step 1: User info (pre-fill from query param, then localStorage)
|
|
const { postalCode: savedPostalCode, setPostalCode: persistPostalCode } = usePostalCode();
|
|
const [postalCode, setPostalCode] = useState(searchParams.get('postalCode') || savedPostalCode || '');
|
|
const [userName, setUserName] = useState('');
|
|
const [userEmail, setUserEmail] = useState('');
|
|
|
|
// Step 2: Representatives
|
|
const [representatives, setRepresentatives] = useState<Representative[]>([]);
|
|
const [lookupLoading, setLookupLoading] = useState(false);
|
|
|
|
// Step 3: Send email
|
|
const [sendingTo, setSendingTo] = useState<string | null>(null);
|
|
const [sentTo, setSentTo] = useState<Set<string>>(new Set());
|
|
const [editSubject, setEditSubject] = useState('');
|
|
const [editBody, setEditBody] = useState('');
|
|
const [linkCopied, setLinkCopied] = useState(false);
|
|
const [relatedData, setRelatedData] = useState<{ campaigns: any[]; shifts: any[] }>({ campaigns: [], shifts: [] });
|
|
|
|
useEffect(() => {
|
|
if (campaign) {
|
|
document.title = `${campaign.title} | ${siteSettings?.organizationName || 'Changemaker'}`;
|
|
}
|
|
}, [campaign, siteSettings?.organizationName]);
|
|
|
|
useEffect(() => {
|
|
fetchCampaign();
|
|
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Fetch related content
|
|
useEffect(() => {
|
|
if (!slug) return;
|
|
axios.get(`${apiBase}/campaigns/${slug}/related`)
|
|
.then(({ data }) => setRelatedData(data))
|
|
.catch(() => {});
|
|
}, [slug]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
const fetchCampaign = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await axios.get<Campaign>(`${apiBase}/campaigns/${slug}/details`);
|
|
setCampaign(data);
|
|
setEditSubject(data.emailSubject);
|
|
setEditBody(data.emailBody);
|
|
} catch {
|
|
setError('Campaign not found or is no longer active.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleLookup = async () => {
|
|
if (!postalCode.trim()) {
|
|
message.warning('Please enter your postal code');
|
|
return;
|
|
}
|
|
setLookupLoading(true);
|
|
try {
|
|
const code = postalCode.replace(/\s/g, '').toUpperCase();
|
|
const { data } = await axios.get<RepresentativeLookupResponse>(
|
|
`${apiBase}/representatives/by-postal/${code}`
|
|
);
|
|
let reps = data.representatives;
|
|
if (campaign?.targetGovernmentLevels && campaign.targetGovernmentLevels.length > 0) {
|
|
const targetLevels = new Set(campaign.targetGovernmentLevels);
|
|
reps = reps.filter((r) => {
|
|
const level = mapRepSetToLevel(r.representativeSetName);
|
|
return level && targetLevels.has(level);
|
|
});
|
|
}
|
|
setRepresentatives(reps);
|
|
setCurrentStep('reps');
|
|
} catch {
|
|
message.error('Could not look up representatives for this postal code');
|
|
} finally {
|
|
setLookupLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSendSmtp = async (rep: Representative) => {
|
|
if (!campaign) return;
|
|
if (campaign.collectUserInfo && (!userName.trim() || !userEmail.trim())) {
|
|
message.warning('Please enter your name and email in Step 1');
|
|
setCurrentStep('info');
|
|
return;
|
|
}
|
|
if (!rep.email) return;
|
|
|
|
setSendingTo(rep.email);
|
|
try {
|
|
await axios.post(`${apiBase}/campaigns/${slug}/send-email`, {
|
|
userEmail: userEmail || 'anonymous@cmlite.org',
|
|
userName: userName || 'Anonymous',
|
|
postalCode: postalCode.replace(/\s/g, '').toUpperCase(),
|
|
recipientEmail: rep.email,
|
|
recipientName: rep.name,
|
|
recipientTitle: rep.electedOffice,
|
|
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
|
|
emailMethod: 'SMTP',
|
|
customEmailSubject: campaign.allowEmailEditing ? editSubject : undefined,
|
|
customEmailBody: campaign.allowEmailEditing ? editBody : undefined,
|
|
});
|
|
message.success(`Email sent to ${rep.name}`);
|
|
setSentTo((prev) => new Set(prev).add(rep.email!));
|
|
// Persist postal code for cross-page reuse
|
|
if (postalCode) persistPostalCode(postalCode);
|
|
} catch {
|
|
message.error('Failed to send email');
|
|
} finally {
|
|
setSendingTo(null);
|
|
}
|
|
};
|
|
|
|
const handleMailto = async (rep: Representative) => {
|
|
if (!campaign || !rep.email) return;
|
|
const subject = encodeURIComponent(campaign.allowEmailEditing ? editSubject : campaign.emailSubject);
|
|
const body = encodeURIComponent(campaign.allowEmailEditing ? editBody : campaign.emailBody);
|
|
window.open(`mailto:${rep.email}?subject=${subject}&body=${body}`, '_blank');
|
|
try {
|
|
await axios.post(`${apiBase}/campaigns/${slug}/track-mailto`, {
|
|
recipientEmail: rep.email,
|
|
recipientName: rep.name,
|
|
recipientTitle: rep.electedOffice,
|
|
recipientLevel: mapRepSetToLevel(rep.representativeSetName),
|
|
userEmail: userEmail || undefined,
|
|
userName: userName || undefined,
|
|
postalCode: postalCode.replace(/\s/g, '').toUpperCase() || undefined,
|
|
});
|
|
} catch { /* tracking is non-critical */ }
|
|
setSentTo((prev) => new Set(prev).add(rep.email!));
|
|
};
|
|
|
|
const handleCopyLink = () => {
|
|
const url = `${window.location.origin}/campaign/${slug}`;
|
|
navigator.clipboard.writeText(url);
|
|
setLinkCopied(true);
|
|
setTimeout(() => setLinkCopied(false), 2000);
|
|
};
|
|
|
|
if (loading) {
|
|
return <div style={{ textAlign: 'center', padding: 80 }}><Spin size="large" /></div>;
|
|
}
|
|
|
|
if (error || !campaign) {
|
|
return <Result status="404" title="Campaign Not Found" subTitle={error} />;
|
|
}
|
|
|
|
const stepDefs: { key: Step; label: string; shortLabel: string }[] = [
|
|
{ key: 'info', label: 'Enter Your Info', shortLabel: 'Info' },
|
|
{ key: 'reps', label: 'Find Representatives', shortLabel: 'Reps' },
|
|
{ key: 'send', label: 'Send Messages', shortLabel: 'Send' },
|
|
];
|
|
|
|
const stepIndex = stepDefs.findIndex((s) => s.key === currentStep);
|
|
|
|
return (
|
|
<div>
|
|
{/* Hero Section */}
|
|
<div
|
|
style={{
|
|
background: campaign.coverPhoto
|
|
? `linear-gradient(rgba(0,0,0,0.55), rgba(0,0,0,0.55)), url(${campaign.coverPhoto}) center/cover`
|
|
: 'linear-gradient(135deg, #3498db, #2c3e50)',
|
|
borderRadius: 12,
|
|
padding: isMobile ? '32px 16px' : '48px 32px',
|
|
textAlign: 'center',
|
|
marginBottom: 24,
|
|
position: 'relative',
|
|
}}
|
|
>
|
|
<Title level={isMobile ? 3 : 2} style={{ color: '#fff', margin: 0, textShadow: '0 2px 4px rgba(0,0,0,0.3)' }}>
|
|
{campaign.title}
|
|
</Title>
|
|
{campaign.description && (
|
|
<Paragraph style={{ color: 'rgba(255,255,255,0.85)', fontSize: isMobile ? 14 : 16, margin: '12px auto 0', maxWidth: 600 }}>
|
|
{campaign.description}
|
|
</Paragraph>
|
|
)}
|
|
|
|
{/* Stats Circles */}
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: isMobile ? 12 : 20, marginTop: 24, flexWrap: 'wrap' }}>
|
|
{campaign.showEmailCount && (
|
|
<div style={getStatCircleStyle(isMobile)}>
|
|
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.emails}</span>
|
|
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Emails</span>
|
|
</div>
|
|
)}
|
|
{campaign.showResponseWall && (
|
|
<div style={getStatCircleStyle(isMobile)}>
|
|
<span style={{ fontSize: isMobile ? 20 : 24, fontWeight: 700, color: '#fff', lineHeight: 1 }}>{campaign._count.responses}</span>
|
|
<span style={{ fontSize: isMobile ? 9 : 10, color: 'rgba(255,255,255,0.7)', textTransform: 'uppercase', letterSpacing: 1 }}>Responses</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Share Buttons */}
|
|
<div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 20 }}>
|
|
<Button
|
|
icon={linkCopied ? <CheckCircleOutlined /> : <CopyOutlined />}
|
|
onClick={handleCopyLink}
|
|
style={{
|
|
background: 'rgba(255,255,255,0.15)',
|
|
border: '1px solid rgba(255,255,255,0.25)',
|
|
color: '#fff',
|
|
backdropFilter: 'blur(8px)',
|
|
}}
|
|
>
|
|
{linkCopied ? 'Copied!' : 'Copy Link'}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Friends who participated */}
|
|
{campaign?.id && <FriendsCampaignBadge campaignId={campaign.id} />}
|
|
</div>
|
|
|
|
{/* Campaign Milestones / Impact Stories */}
|
|
{campaign?.id && <CampaignCelebration campaignId={campaign.id} />}
|
|
|
|
{/* Cover Video */}
|
|
{campaign.coverVideoId && siteSettings?.enableMediaFeatures !== false && (
|
|
<div style={{ marginBottom: 24, borderRadius: 12, overflow: 'hidden' }}>
|
|
<VideoPlayer
|
|
videoId={campaign.coverVideoId}
|
|
width="100%"
|
|
height="auto"
|
|
controls
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Call to Action */}
|
|
{campaign.callToAction && (
|
|
<Card
|
|
style={{
|
|
marginBottom: 24,
|
|
background: '#1b2838',
|
|
border: `1px solid ${token.colorPrimary}40`,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 16, color: 'rgba(255,255,255,0.85)' }}>{campaign.callToAction}</Text>
|
|
</Card>
|
|
)}
|
|
|
|
<AdBanner ads={campaignAds} placement="campaign_detail" />
|
|
|
|
{/* Step Indicator */}
|
|
<div
|
|
style={{
|
|
display: 'flex',
|
|
background: '#1b2838',
|
|
borderRadius: 8,
|
|
padding: isMobile ? '8px 8px' : '12px 16px',
|
|
marginBottom: 24,
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
}}
|
|
>
|
|
{stepDefs.map((step, i) => {
|
|
const isActive = step.key === currentStep;
|
|
const isCompleted = i < stepIndex;
|
|
const isClickable = (step.key === 'info') ||
|
|
(step.key === 'reps' && representatives.length > 0) ||
|
|
(step.key === 'send' && representatives.length > 0);
|
|
|
|
return (
|
|
<div
|
|
key={step.key}
|
|
role="button"
|
|
tabIndex={isClickable ? 0 : -1}
|
|
onClick={() => isClickable && setCurrentStep(step.key)}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === 'Enter' || e.key === ' ') && isClickable) {
|
|
e.preventDefault();
|
|
setCurrentStep(step.key);
|
|
}
|
|
}}
|
|
aria-label={`Step ${i + 1}: ${step.label}${isCompleted ? ' (completed)' : ''}${isActive ? ' (current)' : ''}`}
|
|
style={{
|
|
flex: 1,
|
|
textAlign: 'center',
|
|
padding: isMobile ? '6px 2px' : '8px 4px',
|
|
cursor: isClickable ? 'pointer' : 'default',
|
|
color: isActive ? token.colorPrimary : isCompleted ? token.colorSuccess : 'rgba(255,255,255,0.4)',
|
|
fontWeight: isActive ? 700 : 400,
|
|
fontSize: isMobile ? 12 : 14,
|
|
position: 'relative',
|
|
transition: 'color 0.2s',
|
|
outline: 'none',
|
|
}}
|
|
>
|
|
{isCompleted && <CheckCircleOutlined style={{ marginRight: isMobile ? 4 : 6 }} />}
|
|
{isMobile ? step.shortLabel : step.label}
|
|
{i < stepDefs.length - 1 && (
|
|
<ArrowRightOutlined
|
|
style={{
|
|
position: 'absolute',
|
|
right: -4,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
fontSize: 11,
|
|
color: 'rgba(255,255,255,0.2)',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Step 1: Your Information */}
|
|
{currentStep === 'info' && (
|
|
<Card
|
|
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
|
>
|
|
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>Your Information</Title>
|
|
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
|
|
We need some basic information to find your representatives and track campaign engagement.
|
|
</Text>
|
|
|
|
<div style={{ maxWidth: 500 }}>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
|
Your Postal Code *
|
|
</Text>
|
|
<Input
|
|
placeholder="e.g. T5K 2M5"
|
|
value={postalCode}
|
|
onChange={(e) => setPostalCode(e.target.value)}
|
|
onPressEnter={handleLookup}
|
|
size="large"
|
|
style={{ textTransform: 'uppercase' }}
|
|
disabled={lookupLoading}
|
|
/>
|
|
</div>
|
|
|
|
{campaign.collectUserInfo && (
|
|
<>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
|
Your Name
|
|
</Text>
|
|
<Input
|
|
placeholder="Your full name"
|
|
value={userName}
|
|
onChange={(e) => setUserName(e.target.value)}
|
|
size="large"
|
|
disabled={lookupLoading}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: 'rgba(255,255,255,0.85)', fontWeight: 600, display: 'block', marginBottom: 6 }}>
|
|
Your Email (Optional - If you would like a reply)
|
|
</Text>
|
|
<Input
|
|
placeholder="your@email.com"
|
|
value={userEmail}
|
|
onChange={(e) => setUserEmail(e.target.value)}
|
|
size="large"
|
|
type="email"
|
|
disabled={lookupLoading}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
icon={<SearchOutlined />}
|
|
onClick={handleLookup}
|
|
loading={lookupLoading}
|
|
style={{ marginTop: 8, background: '#005a9c' }}
|
|
>
|
|
Find My Representatives
|
|
</Button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 2: Representatives */}
|
|
{currentStep === 'reps' && (
|
|
<Card
|
|
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
|
>
|
|
<Title level={4} style={{ color: '#fff', marginBottom: 16 }}>
|
|
Your Representatives ({representatives.length})
|
|
</Title>
|
|
|
|
{representatives.length === 0 ? (
|
|
<Result
|
|
status="info"
|
|
title="No Representatives Found"
|
|
subTitle="No representatives matched the target levels for this campaign."
|
|
/>
|
|
) : (
|
|
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
|
{representatives.map((rep, idx) => {
|
|
const level = mapRepSetToLevel(rep.representativeSetName);
|
|
const isSent = rep.email ? sentTo.has(rep.email) : false;
|
|
|
|
return (
|
|
<div
|
|
key={rep.id || idx}
|
|
style={{
|
|
display: 'flex',
|
|
flexDirection: isMobile ? 'column' : 'row',
|
|
alignItems: isMobile ? 'flex-start' : 'center',
|
|
gap: isMobile ? 12 : 16,
|
|
padding: '16px',
|
|
background: '#0d1b2a',
|
|
borderRadius: 8,
|
|
border: '1px solid rgba(255,255,255,0.06)',
|
|
}}
|
|
>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
|
<Avatar src={rep.photoUrl} icon={!rep.photoUrl ? <UserOutlined /> : undefined} size={isMobile ? 48 : 56} />
|
|
<div style={{ flex: 1, minWidth: 150 }}>
|
|
<Text strong style={{ color: '#fff', fontSize: 15 }}>{rep.name || 'Unknown'}</Text>
|
|
<br />
|
|
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>{rep.electedOffice}</Text>
|
|
{rep.districtName && (
|
|
<>
|
|
<br />
|
|
<Text style={{ color: 'rgba(255,255,255,0.4)', fontSize: 12 }}>{rep.districtName}</Text>
|
|
</>
|
|
)}
|
|
<div style={{ marginTop: 4 }}>
|
|
{level && <Tag color={GOVERNMENT_LEVEL_COLORS[level]} style={{ fontSize: 11 }}>{GOVERNMENT_LEVEL_LABELS[level]}</Tag>}
|
|
{rep.partyName && <Tag style={{ fontSize: 11 }}>{rep.partyName}</Tag>}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', width: isMobile ? '100%' : 'auto' }}>
|
|
{rep.email && !isSent && (campaign.allowSmtpEmail || campaign.allowMailtoLink) && (
|
|
<>
|
|
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 12, width: '100%', marginBottom: 2 }}>
|
|
Choose how to send your message:
|
|
</Text>
|
|
{campaign.allowSmtpEmail && (
|
|
<Tooltip title="We send the email for you right now — no extra steps">
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
icon={<SendOutlined />}
|
|
loading={sendingTo === rep.email}
|
|
onClick={() => handleSendSmtp(rep)}
|
|
style={{ background: '#005a9c' }}
|
|
>
|
|
Send Now
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
{campaign.allowMailtoLink && (
|
|
<Tooltip title="Opens Gmail, Outlook, or your default email app with the message ready to send">
|
|
<Button
|
|
size="small"
|
|
icon={<MailOutlined />}
|
|
onClick={() => handleMailto(rep)}
|
|
>
|
|
Open in My Email App
|
|
</Button>
|
|
</Tooltip>
|
|
)}
|
|
</>
|
|
)}
|
|
{isSent && (
|
|
<Tag color="success" icon={<CheckCircleOutlined />} style={{ margin: 0, padding: '4px 12px' }}>
|
|
Sent
|
|
</Tag>
|
|
)}
|
|
{!rep.email && (
|
|
<Text style={{ color: 'rgba(255,255,255,0.3)', fontSize: 12 }}>No email available</Text>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</Space>
|
|
)}
|
|
|
|
{representatives.length > 0 && (
|
|
<div style={{ marginTop: 16 }}>
|
|
<Button type="link" onClick={() => setCurrentStep('send')} style={{ padding: 0, color: token.colorPrimary }}>
|
|
View / Edit Email Message <ArrowRightOutlined />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 3: Email Preview */}
|
|
{currentStep === 'send' && campaign && (
|
|
<Card
|
|
style={{ marginBottom: 24, background: '#1b2838', border: '1px solid rgba(255,255,255,0.08)' }}
|
|
>
|
|
<Title level={4} style={{ color: '#fff', marginBottom: 4 }}>
|
|
<MailOutlined style={{ marginRight: 8 }} />Email Preview
|
|
</Title>
|
|
{campaign.allowEmailEditing && (
|
|
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 16 }}>
|
|
You can edit this message before sending to your representatives:
|
|
</Text>
|
|
)}
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Subject</Text>
|
|
{campaign.allowEmailEditing ? (
|
|
<Input
|
|
value={editSubject}
|
|
onChange={(e) => setEditSubject(e.target.value)}
|
|
size="large"
|
|
/>
|
|
) : (
|
|
<div style={{ padding: '10px 14px', background: '#0d1b2a', borderRadius: 6, border: '1px solid rgba(255,255,255,0.08)', color: '#fff', fontWeight: 600 }}>
|
|
{campaign.emailSubject}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 13, display: 'block', marginBottom: 4 }}>Message</Text>
|
|
{campaign.allowEmailEditing ? (
|
|
<Input.TextArea
|
|
value={editBody}
|
|
onChange={(e) => setEditBody(e.target.value)}
|
|
rows={10}
|
|
/>
|
|
) : (
|
|
<div style={{
|
|
padding: '14px',
|
|
background: '#0d1b2a',
|
|
borderRadius: 6,
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
color: 'rgba(255,255,255,0.85)',
|
|
whiteSpace: 'pre-wrap',
|
|
maxHeight: 400,
|
|
overflow: 'auto',
|
|
lineHeight: 1.7,
|
|
}}>
|
|
{campaign.emailBody}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<Button type="link" onClick={() => setCurrentStep('reps')} style={{ padding: 0, color: token.colorPrimary }}>
|
|
Back to Representatives
|
|
</Button>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Post-Send CTA */}
|
|
{sentTo.size > 0 && (
|
|
<Card
|
|
style={{
|
|
marginBottom: 24,
|
|
background: 'linear-gradient(135deg, #1b2838 0%, #0d2137 100%)',
|
|
border: '1px solid rgba(82, 196, 26, 0.3)',
|
|
borderRadius: 12,
|
|
textAlign: 'center',
|
|
}}
|
|
>
|
|
<CheckCircleOutlined style={{ fontSize: 32, color: '#52c41a', marginBottom: 8 }} />
|
|
<Title level={4} style={{ color: '#fff', margin: '0 0 4px' }}>
|
|
You've made your voice heard!
|
|
</Title>
|
|
<Text style={{ color: 'rgba(255,255,255,0.65)', display: 'block', marginBottom: 20 }}>
|
|
Want to amplify your impact? Share this campaign or start your own.
|
|
</Text>
|
|
<Space wrap size={12} style={{ justifyContent: 'center' }}>
|
|
{!isAuthenticated && (
|
|
<Link to={`/login?redirect=/campaigns/create`}>
|
|
<Button icon={<UserOutlined />} style={{ borderColor: token.colorPrimary, color: token.colorPrimary }}>
|
|
Create an Account
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
{siteSettings?.enableMap !== false && (
|
|
<Link to="/shifts">
|
|
<Button icon={<ScheduleOutlined />} style={{ borderColor: '#52c41a', color: '#52c41a' }}>
|
|
Volunteer for a Shift
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
<Link to="/campaigns/create">
|
|
<Button type="primary" icon={<RocketOutlined />}>
|
|
Start Your Own Campaign
|
|
</Button>
|
|
</Link>
|
|
{siteSettings?.enableMediaFeatures !== false && (
|
|
<Link to="/gallery">
|
|
<Button icon={<PlayCircleOutlined />} style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}>
|
|
Watch Our Content
|
|
</Button>
|
|
</Link>
|
|
)}
|
|
<Button
|
|
icon={<ShareAltOutlined />}
|
|
onClick={handleCopyLink}
|
|
style={{ borderColor: 'rgba(255,255,255,0.25)', color: 'rgba(255,255,255,0.85)' }}
|
|
>
|
|
Share This Campaign
|
|
</Button>
|
|
</Space>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Response Wall CTA */}
|
|
{campaign.showResponseWall && (
|
|
<div
|
|
style={{
|
|
textAlign: 'center',
|
|
padding: isMobile ? '32px 16px' : '40px 24px',
|
|
background: '#1b2838',
|
|
borderRadius: 12,
|
|
border: '1px solid rgba(255,255,255,0.08)',
|
|
}}
|
|
>
|
|
<MessageOutlined style={{ fontSize: 28, color: '#667eea', marginBottom: 8 }} />
|
|
<Title level={4} style={{ color: '#fff' }}>See What People Are Saying</Title>
|
|
<Text style={{ color: 'rgba(255,255,255,0.55)', display: 'block', marginBottom: 20 }}>
|
|
Check out responses to people who have taken action on this campaign
|
|
</Text>
|
|
<Link to={`/campaign/${slug}/responses`}>
|
|
<Button
|
|
type="primary"
|
|
size="large"
|
|
style={{
|
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
|
border: 'none',
|
|
borderRadius: 50,
|
|
padding: '0 32px',
|
|
height: 48,
|
|
fontWeight: 600,
|
|
fontSize: 15,
|
|
}}
|
|
>
|
|
View Response Wall
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
|
|
{/* Related Content */}
|
|
<RelatedContent campaigns={relatedData.campaigns} shifts={relatedData.shifts} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function getStatCircleStyle(isMobile: boolean): React.CSSProperties {
|
|
const size = isMobile ? 64 : 80;
|
|
return {
|
|
background: 'rgba(255,255,255,0.15)',
|
|
border: '2px solid rgba(255,255,255,0.25)',
|
|
borderRadius: '50%',
|
|
width: size,
|
|
height: size,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backdropFilter: 'blur(10px)',
|
|
};
|
|
}
|
|
|