bunker-admin 08d8066157 Add ticketed events, Jitsi meeting integration, social features, and calendar system
- 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
2026-03-06 14:33:33 -07:00

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)',
};
}