mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 13:36:23 +00:00
- Next.js API 프록시 라우트 8개 생성 (User/Event Analytics) - analyticsClient baseURL을 프록시 경로로 변경 - analyticsApi 경로에서 /api/v1 접두사 제거 - 404/400 에러에 대한 사용자 친화적 에러 처리 추가 - Dashboard, Event Detail, Analytics 페이지 에러 핸들링 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1086 lines
41 KiB
TypeScript
1086 lines
41 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useRouter, useParams } from 'next/navigation';
|
||
import {
|
||
Box,
|
||
Container,
|
||
Typography,
|
||
Card,
|
||
CardContent,
|
||
Chip,
|
||
Button,
|
||
IconButton,
|
||
Grid,
|
||
Menu,
|
||
MenuItem,
|
||
Divider,
|
||
LinearProgress,
|
||
CircularProgress,
|
||
Alert,
|
||
Tooltip as MuiTooltip,
|
||
} from '@mui/material';
|
||
import {
|
||
MoreVert,
|
||
Group,
|
||
Visibility,
|
||
TrendingUp,
|
||
Share,
|
||
CardGiftcard,
|
||
AttachMoney,
|
||
People,
|
||
Edit,
|
||
Download,
|
||
Person,
|
||
Phone,
|
||
Email,
|
||
ShoppingCart,
|
||
Warning,
|
||
LocalFireDepartment,
|
||
Star,
|
||
NewReleases,
|
||
Refresh as RefreshIcon,
|
||
} from '@mui/icons-material';
|
||
import { Line, Bar } from 'react-chartjs-2';
|
||
import {
|
||
Chart as ChartJS,
|
||
CategoryScale,
|
||
LinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
BarElement,
|
||
Title,
|
||
Tooltip as ChartTooltip,
|
||
Legend,
|
||
Filler,
|
||
} from 'chart.js';
|
||
import { analyticsApi } from '@/entities/analytics/api/analyticsApi';
|
||
|
||
// Chart.js 등록
|
||
ChartJS.register(
|
||
CategoryScale,
|
||
LinearScale,
|
||
PointElement,
|
||
LineElement,
|
||
BarElement,
|
||
Title,
|
||
ChartTooltip,
|
||
Legend,
|
||
Filler
|
||
);
|
||
|
||
// 디자인 시스템 색상
|
||
const colors = {
|
||
pink: '#F472B6',
|
||
purple: '#C084FC',
|
||
purpleLight: '#E9D5FF',
|
||
blue: '#60A5FA',
|
||
mint: '#34D399',
|
||
orange: '#FB923C',
|
||
yellow: '#FBBF24',
|
||
gray: {
|
||
900: '#1A1A1A',
|
||
700: '#4A4A4A',
|
||
500: '#9E9E9E',
|
||
300: '#D9D9D9',
|
||
100: '#F5F5F5',
|
||
},
|
||
};
|
||
|
||
// Mock 데이터
|
||
const mockEventData = {
|
||
id: '1',
|
||
title: 'SNS 팔로우 이벤트',
|
||
status: 'active' as const,
|
||
startDate: '2025-01-15',
|
||
endDate: '2025-02-15',
|
||
prize: '커피 쿠폰',
|
||
method: 'SNS 팔로우',
|
||
cost: 250000,
|
||
channels: ['우리동네TV', '링고비즈', 'SNS'],
|
||
participants: 128,
|
||
views: 456,
|
||
roi: 450,
|
||
conversion: 28,
|
||
targetParticipants: 200,
|
||
isAIRecommended: true,
|
||
isUrgent: false,
|
||
isPopular: true,
|
||
isHighROI: true,
|
||
isNew: false,
|
||
};
|
||
|
||
const recentParticipants = [
|
||
{ name: '김*진', phone: '010-****-1234', time: '5분 전' },
|
||
{ name: '이*수', phone: '010-****-5678', time: '12분 전' },
|
||
{ name: '박*영', phone: '010-****-9012', time: '25분 전' },
|
||
{ name: '최*민', phone: '010-****-3456', time: '1시간 전' },
|
||
{ name: '정*희', phone: '010-****-7890', time: '2시간 전' },
|
||
];
|
||
|
||
|
||
// 헬퍼 함수
|
||
const getMethodIcon = (method: string) => {
|
||
switch (method) {
|
||
case '전화번호 입력':
|
||
return <Phone sx={{ fontSize: 18 }} />;
|
||
case 'SNS 팔로우':
|
||
return <Share sx={{ fontSize: 18 }} />;
|
||
case '구매 인증':
|
||
return <ShoppingCart sx={{ fontSize: 18 }} />;
|
||
case '이메일 등록':
|
||
return <Email sx={{ fontSize: 18 }} />;
|
||
default:
|
||
return <Share sx={{ fontSize: 18 }} />;
|
||
}
|
||
};
|
||
|
||
const calculateProgress = (event: typeof mockEventData) => {
|
||
if (event.status !== 'active') return 0;
|
||
const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime();
|
||
const elapsed = Date.now() - new Date(event.startDate).getTime();
|
||
return Math.min(Math.max((elapsed / total) * 100, 0), 100);
|
||
};
|
||
|
||
export default function EventDetailPage() {
|
||
const router = useRouter();
|
||
const params = useParams();
|
||
const eventId = params.eventId as string;
|
||
|
||
const [event, setEvent] = useState(mockEventData);
|
||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
|
||
const [chartPeriod, setChartPeriod] = useState<'7d' | '30d' | 'all'>('7d');
|
||
const [loading, setLoading] = useState(true);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [analyticsData, setAnalyticsData] = useState<any>(null);
|
||
|
||
// Analytics API 호출
|
||
const fetchAnalytics = async (forceRefresh = false) => {
|
||
try {
|
||
if (forceRefresh) {
|
||
console.log('🔄 Analytics 데이터 새로고침...');
|
||
setRefreshing(true);
|
||
} else {
|
||
console.log('📊 Analytics 데이터 로딩...');
|
||
setLoading(true);
|
||
}
|
||
setError(null);
|
||
|
||
// Event Analytics API 병렬 호출
|
||
const [dashboard, timeline, roi, channels] = await Promise.all([
|
||
analyticsApi.getEventAnalytics(eventId, { refresh: forceRefresh }),
|
||
analyticsApi.getEventTimelineAnalytics(eventId, {
|
||
interval: chartPeriod === '7d' ? 'daily' : chartPeriod === '30d' ? 'daily' : 'daily',
|
||
}),
|
||
analyticsApi.getEventRoiAnalytics(eventId, { includeProjection: true }),
|
||
analyticsApi.getEventChannelAnalytics(eventId, {}),
|
||
]);
|
||
|
||
console.log('✅ Dashboard 데이터:', dashboard);
|
||
console.log('✅ Timeline 데이터:', timeline);
|
||
console.log('✅ ROI 데이터:', roi);
|
||
console.log('✅ Channels 데이터:', channels);
|
||
|
||
// Analytics 데이터 저장
|
||
const formattedAnalyticsData = {
|
||
dashboard,
|
||
timeline,
|
||
roi,
|
||
channels,
|
||
};
|
||
|
||
setAnalyticsData(formattedAnalyticsData);
|
||
|
||
// Event 객체 업데이트 - Analytics 데이터 반영
|
||
setEvent(prev => ({
|
||
...prev,
|
||
participants: dashboard.summary.participants,
|
||
views: dashboard.summary.totalViews,
|
||
conversion: dashboard.summary.conversionRate * 100,
|
||
roi: dashboard.roi.roi,
|
||
title: dashboard.eventTitle,
|
||
}));
|
||
|
||
console.log('✅ Analytics 데이터 로딩 완료');
|
||
} catch (err: any) {
|
||
console.error('❌ Analytics 데이터 로딩 실패:', err);
|
||
|
||
// 404 또는 400 에러는 아직 Analytics 데이터가 없는 경우
|
||
if (err.response?.status === 404 || err.response?.status === 400) {
|
||
console.log('ℹ️ Analytics 데이터가 아직 생성되지 않았습니다.');
|
||
setError('이벤트의 Analytics 데이터가 아직 생성되지 않았습니다. 참여자가 생기면 자동으로 생성됩니다.');
|
||
} else {
|
||
setError(err.message || 'Analytics 데이터를 불러오는데 실패했습니다.');
|
||
}
|
||
} finally {
|
||
setLoading(false);
|
||
setRefreshing(false);
|
||
}
|
||
};
|
||
|
||
// 초기 데이터 로드
|
||
useEffect(() => {
|
||
fetchAnalytics();
|
||
}, [eventId]);
|
||
|
||
// 차트 기간 변경 시 Timeline 데이터 다시 로드
|
||
useEffect(() => {
|
||
if (analyticsData) {
|
||
fetchAnalytics();
|
||
}
|
||
}, [chartPeriod]);
|
||
|
||
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
|
||
setAnchorEl(event.currentTarget);
|
||
};
|
||
|
||
const handleMenuClose = () => {
|
||
setAnchorEl(null);
|
||
};
|
||
|
||
const handleRefresh = () => {
|
||
fetchAnalytics(true);
|
||
};
|
||
|
||
// 차트 데이터 생성 함수
|
||
const generateParticipationTrendData = () => {
|
||
if (!analyticsData?.timeline) {
|
||
return {
|
||
labels: [],
|
||
datasets: [{
|
||
label: '일별 참여자',
|
||
data: [],
|
||
borderColor: colors.blue,
|
||
backgroundColor: `${colors.blue}40`,
|
||
fill: true,
|
||
tension: 0.4,
|
||
}],
|
||
};
|
||
}
|
||
|
||
const timelineData = analyticsData.timeline;
|
||
const dataPoints = timelineData.dataPoints || [];
|
||
|
||
// 데이터 포인트를 날짜별로 그룹화
|
||
const dailyData = new Map<string, number>();
|
||
dataPoints.forEach((point: any) => {
|
||
const date = new Date(point.timestamp);
|
||
const dateKey = `${date.getMonth() + 1}/${date.getDate()}`;
|
||
dailyData.set(dateKey, (dailyData.get(dateKey) || 0) + point.participants);
|
||
});
|
||
|
||
const labels = Array.from(dailyData.keys());
|
||
const data = Array.from(dailyData.values());
|
||
|
||
return {
|
||
labels,
|
||
datasets: [{
|
||
label: '일별 참여자',
|
||
data,
|
||
borderColor: colors.blue,
|
||
backgroundColor: `${colors.blue}40`,
|
||
fill: true,
|
||
tension: 0.4,
|
||
}],
|
||
};
|
||
};
|
||
|
||
const generateChannelPerformanceData = () => {
|
||
if (!analyticsData?.channels?.channels) {
|
||
return {
|
||
labels: [],
|
||
datasets: [{
|
||
label: '참여자 수',
|
||
data: [],
|
||
backgroundColor: [],
|
||
borderRadius: 8,
|
||
}],
|
||
};
|
||
}
|
||
|
||
const channelColors = [colors.pink, colors.blue, colors.orange, colors.purple, colors.mint, colors.yellow];
|
||
const channels = analyticsData.channels.channels;
|
||
|
||
const labels = channels.map((ch: any) => {
|
||
let channelName = ch.channelName || ch.channelType || '알 수 없음';
|
||
// 채널명 디코딩 처리
|
||
if (channelName.includes('%')) {
|
||
try {
|
||
channelName = decodeURIComponent(channelName);
|
||
} catch (e) {
|
||
console.warn('⚠️ 채널명 디코딩 실패:', channelName);
|
||
}
|
||
}
|
||
return channelName;
|
||
});
|
||
|
||
const data = channels.map((ch: any) => ch.metrics?.participants || 0);
|
||
const backgroundColor = channels.map((_: any, idx: number) => channelColors[idx % channelColors.length]);
|
||
|
||
return {
|
||
labels,
|
||
datasets: [{
|
||
label: '참여자 수',
|
||
data,
|
||
backgroundColor,
|
||
borderRadius: 8,
|
||
}],
|
||
};
|
||
};
|
||
|
||
const generateRoiTrendData = () => {
|
||
// ROI는 현재 시점의 값만 있으므로 간단한 추이를 표시
|
||
if (!analyticsData?.roi) {
|
||
return {
|
||
labels: ['현재'],
|
||
datasets: [{
|
||
label: 'ROI (%)',
|
||
data: [0],
|
||
borderColor: colors.mint,
|
||
backgroundColor: `${colors.mint}40`,
|
||
fill: true,
|
||
tension: 0.4,
|
||
}],
|
||
};
|
||
}
|
||
|
||
const currentRoi = analyticsData.roi.roi?.roiPercentage || 0;
|
||
|
||
// 단순 추정: 초기 0에서 현재 ROI까지의 추이
|
||
const labels = ['시작', '1주차', '2주차', '3주차', '현재'];
|
||
const data = [0, currentRoi * 0.3, currentRoi * 0.5, currentRoi * 0.75, currentRoi];
|
||
|
||
return {
|
||
labels,
|
||
datasets: [{
|
||
label: 'ROI (%)',
|
||
data,
|
||
borderColor: colors.mint,
|
||
backgroundColor: `${colors.mint}40`,
|
||
fill: true,
|
||
tension: 0.4,
|
||
}],
|
||
};
|
||
};
|
||
|
||
const getStatusColor = (status: string) => {
|
||
switch (status) {
|
||
case 'active':
|
||
return 'success';
|
||
case 'scheduled':
|
||
return 'info';
|
||
case 'ended':
|
||
return 'default';
|
||
default:
|
||
return 'default';
|
||
}
|
||
};
|
||
|
||
const getStatusText = (status: string) => {
|
||
switch (status) {
|
||
case 'active':
|
||
return '진행중';
|
||
case 'scheduled':
|
||
return '예정';
|
||
case 'ended':
|
||
return '종료';
|
||
default:
|
||
return status;
|
||
}
|
||
};
|
||
|
||
// 로딩 중
|
||
if (loading) {
|
||
return (
|
||
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<CircularProgress size={60} />
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
// 에러 발생
|
||
if (error) {
|
||
return (
|
||
<Box sx={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', p: 3 }}>
|
||
<Alert severity="error" sx={{ maxWidth: 600 }}>
|
||
{error}
|
||
</Alert>
|
||
</Box>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: { xs: 5, sm: 10 } }}>
|
||
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 3, sm: 6, md: 10 } }}>
|
||
{/* Event Header */}
|
||
<Box sx={{ mb: { xs: 4, sm: 8 } }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: { xs: 2, sm: 4 } }}>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: { xs: '1.25rem', sm: '2rem' } }}>
|
||
{event.title}
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||
<MuiTooltip title="데이터 새로고침">
|
||
<IconButton onClick={handleRefresh} disabled={refreshing}>
|
||
<RefreshIcon
|
||
sx={{
|
||
animation: refreshing ? 'spin 1s linear infinite' : 'none',
|
||
'@keyframes spin': {
|
||
'0%': { transform: 'rotate(0deg)' },
|
||
'100%': { transform: 'rotate(360deg)' },
|
||
},
|
||
}}
|
||
/>
|
||
</IconButton>
|
||
</MuiTooltip>
|
||
<IconButton onClick={handleMenuOpen}>
|
||
<MoreVert />
|
||
</IconButton>
|
||
</Box>
|
||
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
|
||
<MenuItem onClick={handleMenuClose}>
|
||
<Edit sx={{ mr: 2 }} /> 이벤트 수정
|
||
</MenuItem>
|
||
<MenuItem onClick={handleMenuClose}>
|
||
<Share sx={{ mr: 2 }} /> 공유하기
|
||
</MenuItem>
|
||
<MenuItem onClick={handleMenuClose}>
|
||
<Download sx={{ mr: 2 }} /> 데이터 다운로드
|
||
</MenuItem>
|
||
<Divider />
|
||
<MenuItem onClick={handleMenuClose} sx={{ color: 'error.main' }}>
|
||
이벤트 삭제
|
||
</MenuItem>
|
||
</Menu>
|
||
</Box>
|
||
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1, sm: 2 }, flexWrap: 'wrap', mb: { xs: 2, sm: 4 } }}>
|
||
<Chip
|
||
label={getStatusText(event.status)}
|
||
color={getStatusColor(event.status) as any}
|
||
size="medium"
|
||
sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }}
|
||
/>
|
||
{event.isAIRecommended && (
|
||
<Chip label="AI 추천" size="medium" sx={{ bgcolor: colors.purpleLight, color: colors.purple, fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }} />
|
||
)}
|
||
{event.isUrgent && (
|
||
<Chip
|
||
icon={<Warning sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }} />}
|
||
label="마감임박"
|
||
size="medium"
|
||
sx={{ bgcolor: '#FEF3C7', color: '#92400E', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }}
|
||
/>
|
||
)}
|
||
{event.isPopular && (
|
||
<Chip
|
||
icon={<LocalFireDepartment sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }} />}
|
||
label="인기"
|
||
size="medium"
|
||
sx={{ bgcolor: '#FEE2E2', color: '#991B1B', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }}
|
||
/>
|
||
)}
|
||
{event.isHighROI && (
|
||
<Chip
|
||
icon={<Star sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }} />}
|
||
label="높은 ROI"
|
||
size="medium"
|
||
sx={{ bgcolor: '#DCFCE7', color: '#166534', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }}
|
||
/>
|
||
)}
|
||
{event.isNew && (
|
||
<Chip
|
||
icon={<NewReleases sx={{ fontSize: { xs: '1rem', sm: '1.25rem' } }} />}
|
||
label="신규"
|
||
size="medium"
|
||
sx={{ bgcolor: '#DBEAFE', color: '#1E40AF', fontSize: { xs: '0.75rem', sm: '0.875rem' }, height: { xs: 24, sm: 32 } }}
|
||
/>
|
||
)}
|
||
</Box>
|
||
|
||
<Typography variant="body1" color="text.secondary" sx={{ fontSize: { xs: '0.875rem', sm: '1rem' }, mb: { xs: 2, sm: 4 } }}>
|
||
📅 {event.startDate} ~ {event.endDate}
|
||
</Typography>
|
||
|
||
{/* 진행률 바 (진행중인 이벤트만) */}
|
||
{event.status === 'active' && (
|
||
<Box>
|
||
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: { xs: 1, sm: 2 } }}>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
이벤트 진행률
|
||
</Typography>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{Math.round(calculateProgress(event))}%
|
||
</Typography>
|
||
</Box>
|
||
<LinearProgress
|
||
variant="determinate"
|
||
value={calculateProgress(event)}
|
||
sx={{
|
||
height: { xs: 6, sm: 10 },
|
||
borderRadius: 5,
|
||
bgcolor: colors.gray[100],
|
||
'& .MuiLinearProgress-bar': {
|
||
background: `linear-gradient(90deg, ${colors.mint} 0%, ${colors.blue} 100%)`,
|
||
borderRadius: 5,
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
)}
|
||
</Box>
|
||
|
||
{/* Real-time KPIs */}
|
||
<Box sx={{ mb: { xs: 4, sm: 10 } }}>
|
||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: { xs: 1.5, sm: 0 }, mb: { xs: 2.5, sm: 6 } }}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1rem', sm: '1.5rem' } }}>
|
||
실시간 현황
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 0.75, sm: 1.5 }, color: 'success.main' }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 6, sm: 10 },
|
||
height: { xs: 6, sm: 10 },
|
||
borderRadius: '50%',
|
||
bgcolor: 'success.main',
|
||
animation: 'pulse 2s infinite',
|
||
'@keyframes pulse': {
|
||
'0%, 100%': { opacity: 1 },
|
||
'50%': { opacity: 0.5 },
|
||
},
|
||
}}
|
||
/>
|
||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: { xs: '0.6875rem', sm: '0.875rem' } }}>
|
||
실시간 업데이트
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
|
||
<Grid container spacing={{ xs: 1.5, sm: 6 }}>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
borderRadius: { xs: 2, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
|
||
height: '100%',
|
||
}}
|
||
>
|
||
<CardContent sx={{
|
||
textAlign: 'center',
|
||
pt: { xs: 1.5, sm: 6 },
|
||
pb: { xs: 1.5, sm: 6 },
|
||
px: { xs: 1, sm: 4 },
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
height: '100%',
|
||
}}>
|
||
<Group sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
|
||
참여자
|
||
</Typography>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' }, lineHeight: 1.2 }}>
|
||
{event.participants}명
|
||
</Typography>
|
||
<Typography variant="caption" sx={{ color: 'rgba(255,255,255,0.8)', mt: { xs: 0.25, sm: 1 }, display: 'block', fontSize: { xs: '0.5rem', sm: '0.75rem' }, lineHeight: 1.3, minHeight: { xs: '1.3em', sm: 'auto' } }}>
|
||
목표: {event.targetParticipants}명<br />
|
||
({Math.round((event.participants / event.targetParticipants) * 100)}%)
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
borderRadius: { xs: 2, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
|
||
height: '100%',
|
||
}}
|
||
>
|
||
<CardContent sx={{
|
||
textAlign: 'center',
|
||
pt: { xs: 1.5, sm: 6 },
|
||
pb: { xs: 1.5, sm: 6 },
|
||
px: { xs: 1, sm: 4 },
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
height: '100%',
|
||
}}>
|
||
<Visibility sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
|
||
조회수
|
||
</Typography>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' }, lineHeight: 1.2 }}>
|
||
{event.views}
|
||
</Typography>
|
||
<Box sx={{ mt: { xs: 0.25, sm: 1 }, minHeight: { xs: '1.3em', sm: 0 } }} />
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
borderRadius: { xs: 2, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
background: `linear-gradient(135deg, ${colors.mint} 0%, #6EE7B7 100%)`,
|
||
height: '100%',
|
||
}}
|
||
>
|
||
<CardContent sx={{
|
||
textAlign: 'center',
|
||
pt: { xs: 1.5, sm: 6 },
|
||
pb: { xs: 1.5, sm: 6 },
|
||
px: { xs: 1, sm: 4 },
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
height: '100%',
|
||
}}>
|
||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
|
||
ROI
|
||
</Typography>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' }, lineHeight: 1.2 }}>
|
||
{event.roi}%
|
||
</Typography>
|
||
<Box sx={{ mt: { xs: 0.25, sm: 1 }, minHeight: { xs: '1.3em', sm: 0 } }} />
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
borderRadius: { xs: 2, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
background: `linear-gradient(135deg, ${colors.orange} 0%, #FCD34D 100%)`,
|
||
height: '100%',
|
||
}}
|
||
>
|
||
<CardContent sx={{
|
||
textAlign: 'center',
|
||
pt: { xs: 1.5, sm: 6 },
|
||
pb: { xs: 1.5, sm: 6 },
|
||
px: { xs: 1, sm: 4 },
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
height: '100%',
|
||
}}>
|
||
<TrendingUp sx={{ fontSize: { xs: 20, sm: 40 }, mb: { xs: 0.5, sm: 2 }, color: 'white' }} />
|
||
<Typography variant="caption" display="block" sx={{ mb: { xs: 0.5, sm: 2 }, color: 'rgba(255,255,255,0.9)', fontSize: { xs: '0.5625rem', sm: '0.75rem' } }}>
|
||
전환율
|
||
</Typography>
|
||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: { xs: '0.9375rem', sm: '1.75rem' }, lineHeight: 1.2 }}>
|
||
{event.conversion}%
|
||
</Typography>
|
||
<Box sx={{ mt: { xs: 0.25, sm: 1 }, minHeight: { xs: '1.3em', sm: 0 } }} />
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
</Box>
|
||
|
||
{/* Chart Section - 참여 추이 */}
|
||
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
📈 참여 추이
|
||
</Typography>
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||
<Box sx={{ display: 'flex', gap: { xs: 1, sm: 2 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Button
|
||
size="medium"
|
||
variant={chartPeriod === '7d' ? 'contained' : 'outlined'}
|
||
onClick={() => setChartPeriod('7d')}
|
||
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
|
||
>
|
||
7일
|
||
</Button>
|
||
<Button
|
||
size="medium"
|
||
variant={chartPeriod === '30d' ? 'contained' : 'outlined'}
|
||
onClick={() => setChartPeriod('30d')}
|
||
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
|
||
>
|
||
30일
|
||
</Button>
|
||
<Button
|
||
size="medium"
|
||
variant={chartPeriod === 'all' ? 'contained' : 'outlined'}
|
||
onClick={() => setChartPeriod('all')}
|
||
sx={{ borderRadius: 2, fontSize: { xs: '0.75rem', sm: '0.875rem' }, py: { xs: 0.5, sm: 1 }, px: { xs: 1.5, sm: 2 } }}
|
||
>
|
||
전체
|
||
</Button>
|
||
</Box>
|
||
|
||
<Box sx={{ height: { xs: 200, sm: 320 } }}>
|
||
<Line
|
||
data={generateParticipationTrendData()}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top' as const,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
padding: 12,
|
||
titleFont: { size: 14, weight: 'bold' },
|
||
bodyFont: { size: 13 },
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.05)',
|
||
},
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Box>
|
||
|
||
{/* Chart Section - 채널별 성과 & ROI 추이 */}
|
||
<Grid container spacing={{ xs: 2, sm: 6 }} sx={{ mb: { xs: 5, sm: 10 } }}>
|
||
<Grid item xs={12} md={6}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
📊 채널별 참여자
|
||
</Typography>
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||
<Box sx={{ height: { xs: 200, sm: 320 } }}>
|
||
<Bar
|
||
data={generateChannelPerformanceData()}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: false,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
padding: 12,
|
||
titleFont: { size: 14, weight: 'bold' },
|
||
bodyFont: { size: 13 },
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.05)',
|
||
},
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
|
||
<Grid item xs={12} md={6}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
💰 ROI 추이
|
||
</Typography>
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||
<Box sx={{ height: { xs: 200, sm: 320 } }}>
|
||
<Line
|
||
data={generateRoiTrendData()}
|
||
options={{
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
plugins: {
|
||
legend: {
|
||
display: true,
|
||
position: 'top' as const,
|
||
},
|
||
tooltip: {
|
||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||
padding: 12,
|
||
titleFont: { size: 14, weight: 'bold' },
|
||
bodyFont: { size: 13 },
|
||
},
|
||
},
|
||
scales: {
|
||
y: {
|
||
beginAtZero: true,
|
||
grid: {
|
||
color: 'rgba(0, 0, 0, 0.05)',
|
||
},
|
||
},
|
||
x: {
|
||
grid: {
|
||
display: false,
|
||
},
|
||
},
|
||
},
|
||
}}
|
||
/>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
|
||
{/* Event Details */}
|
||
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
🎯 이벤트 정보
|
||
</Typography>
|
||
|
||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: { xs: 2, sm: 4 } }}>
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 1.5, sm: 3 }, p: { xs: 3, sm: 4 } }}>
|
||
<CardGiftcard sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.pink }} />
|
||
<Box sx={{ flex: 1 }}>
|
||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||
경품
|
||
</Typography>
|
||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
|
||
{event.prize}
|
||
</Typography>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 1.5, sm: 3 }, p: { xs: 3, sm: 4 } }}>
|
||
{getMethodIcon(event.method)}
|
||
<Box sx={{ flex: 1 }}>
|
||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||
참여 방법
|
||
</Typography>
|
||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
|
||
{event.method}
|
||
</Typography>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 1.5, sm: 3 }, p: { xs: 3, sm: 4 } }}>
|
||
<AttachMoney sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.mint }} />
|
||
<Box sx={{ flex: 1 }}>
|
||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: { xs: 0.5, sm: 1 }, fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||
예상 비용
|
||
</Typography>
|
||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1.125rem' } }}>
|
||
{event.cost.toLocaleString()}원
|
||
</Typography>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: { xs: 1.5, sm: 3 }, p: { xs: 3, sm: 4 } }}>
|
||
<Share sx={{ fontSize: { xs: 20, sm: 28 }, color: colors.blue }} />
|
||
<Box sx={{ flex: 1 }}>
|
||
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: { xs: 1, sm: 2 }, fontSize: { xs: '0.625rem', sm: '0.75rem' } }}>
|
||
배포 채널
|
||
</Typography>
|
||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: { xs: 1, sm: 2 } }}>
|
||
{event.channels.map((channel) => (
|
||
<Chip
|
||
key={channel}
|
||
label={channel}
|
||
size="medium"
|
||
sx={{
|
||
bgcolor: colors.purpleLight,
|
||
color: colors.purple,
|
||
fontWeight: 600,
|
||
fontSize: { xs: '0.75rem', sm: '0.875rem' },
|
||
height: { xs: 24, sm: 32 },
|
||
}}
|
||
/>
|
||
))}
|
||
</Box>
|
||
</Box>
|
||
</CardContent>
|
||
</Card>
|
||
</Box>
|
||
</Box>
|
||
|
||
{/* Quick Actions */}
|
||
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, mb: { xs: 3, sm: 6 }, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
⚡ 빠른 작업
|
||
</Typography>
|
||
<Grid container spacing={{ xs: 2, sm: 4 }}>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
borderRadius: { xs: 3, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||
transform: 'translateY(-4px)',
|
||
},
|
||
}}
|
||
onClick={() => router.push(`/events/${eventId}/participants`)}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 5 } }}>
|
||
<People sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 1, sm: 2 }, color: colors.pink }} />
|
||
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||
참여자 목록
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
borderRadius: { xs: 3, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||
transform: 'translateY(-4px)',
|
||
},
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 5 } }}>
|
||
<Edit sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 1, sm: 2 }, color: colors.blue }} />
|
||
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||
이벤트 수정
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
borderRadius: { xs: 3, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||
transform: 'translateY(-4px)',
|
||
},
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 5 } }}>
|
||
<Share sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 1, sm: 2 }, color: colors.purple }} />
|
||
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||
공유하기
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
<Grid item xs={6} md={3}>
|
||
<Card
|
||
elevation={0}
|
||
sx={{
|
||
cursor: 'pointer',
|
||
borderRadius: { xs: 3, sm: 4 },
|
||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||
transition: 'all 0.2s',
|
||
'&:hover': {
|
||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||
transform: 'translateY(-4px)',
|
||
},
|
||
}}
|
||
>
|
||
<CardContent sx={{ textAlign: 'center', py: { xs: 3, sm: 5 } }}>
|
||
<Download sx={{ fontSize: { xs: 24, sm: 40 }, mb: { xs: 1, sm: 2 }, color: colors.mint }} />
|
||
<Typography variant="body1" sx={{ fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||
데이터 다운
|
||
</Typography>
|
||
</CardContent>
|
||
</Card>
|
||
</Grid>
|
||
</Grid>
|
||
</Box>
|
||
|
||
{/* Recent Participants */}
|
||
<Box sx={{ mb: { xs: 5, sm: 10 } }}>
|
||
<Box sx={{ display: 'flex', flexDirection: { xs: 'column', sm: 'row' }, alignItems: { xs: 'flex-start', sm: 'center' }, justifyContent: 'space-between', gap: { xs: 2, sm: 0 }, mb: { xs: 3, sm: 6 } }}>
|
||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: { xs: '1.125rem', sm: '1.5rem' } }}>
|
||
👥 최근 참여자
|
||
</Typography>
|
||
<Button
|
||
size="medium"
|
||
endIcon={<Box component="span" className="material-icons" sx={{ fontSize: { xs: 14, sm: 18 } }}>chevron_right</Box>}
|
||
onClick={() => router.push(`/events/${eventId}/participants`)}
|
||
sx={{ color: colors.pink, fontWeight: 600, fontSize: { xs: '0.875rem', sm: '1rem' } }}
|
||
>
|
||
전체보기
|
||
</Button>
|
||
</Box>
|
||
|
||
<Card elevation={0} sx={{ borderRadius: { xs: 3, sm: 4 }, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
|
||
<CardContent sx={{ p: { xs: 3, sm: 6 } }}>
|
||
{recentParticipants.map((participant, index) => (
|
||
<Box key={index}>
|
||
{index > 0 && <Divider sx={{ my: { xs: 2, sm: 4 } }} />}
|
||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||
<Box sx={{ display: 'flex', alignItems: 'center', gap: { xs: 1.5, sm: 3 } }}>
|
||
<Box
|
||
sx={{
|
||
width: { xs: 32, sm: 48 },
|
||
height: { xs: 32, sm: 48 },
|
||
borderRadius: '50%',
|
||
bgcolor: colors.purpleLight,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
>
|
||
<Person sx={{ color: colors.purple, fontSize: { xs: 16, sm: 24 } }} />
|
||
</Box>
|
||
<Box>
|
||
<Typography variant="body1" sx={{ fontWeight: 600, mb: { xs: 0.25, sm: 0.5 }, fontSize: { xs: '0.875rem', sm: '1rem' } }}>
|
||
{participant.name}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{participant.phone}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: { xs: '0.75rem', sm: '0.875rem' } }}>
|
||
{participant.time}
|
||
</Typography>
|
||
</Box>
|
||
</Box>
|
||
))}
|
||
</CardContent>
|
||
</Card>
|
||
</Box>
|
||
</Container>
|
||
</Box>
|
||
);
|
||
}
|