이벤트 관리 페이지 개발 완료

- 이벤트 목록 페이지 (/events)
  - 검색, 필터링, 정렬, 페이지네이션 기능
  - 이벤트 카드 클릭 시 상세 페이지로 이동

- 이벤트 상세 페이지 (/events/[eventId])
  - 실시간 KPI 모니터링 (참여자, 조회수, ROI, 전환율)
  - 참여 추세 차트 및 기간 선택 기능
  - 빠른 액션 버튼 (참여자 관리, 수정, 공유, 다운로드)

- 이벤트 참여 페이지 (/events/[eventId]/participate)
  - 공개 페이지 (인증 불필요)
  - 이벤트 배너 및 정보 표시
  - 참여 폼 (이름, 전화번호 자동 포맷팅)
  - 개인정보 동의 및 폼 유효성 검사
  - 중복 참여 체크 및 성공 다이얼로그

- 참여자 목록 페이지 (/events/[eventId]/participants)
  - 검색 및 필터링 (참여 경로, 당첨 상태)
  - 참여자 상세 정보 모달
  - 당첨자 추첨 페이지로 이동
  - 엑셀 다운로드 기능

- 당첨자 추첨 페이지 (/events/[eventId]/draw)
  - 당첨 인원 설정 및 가산점 옵션
  - 추첨 애니메이션 (3단계)
  - 당첨자 목록 표시 (순위별 배지)
  - 재추첨, 엑셀 다운로드, SMS 알림 전송 기능
  - 추첨 이력 관리

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
cherry2250 2025-10-24 16:02:57 +09:00
parent 526bf06182
commit 6a9dcda398
5 changed files with 2190 additions and 0 deletions

View File

@ -0,0 +1,571 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
Container,
Card,
CardContent,
Typography,
Button,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Grid,
} from '@mui/material';
import {
EventNote,
Tune,
Casino,
Download,
Refresh,
Notifications,
List as ListIcon,
Add,
Remove,
Info,
} from '@mui/icons-material';
// Mock 데이터
const mockEventData = {
name: '신규고객 유치 이벤트',
totalParticipants: 127,
participants: [
{ id: '00042', name: '김**', phone: '010-****-1234', channel: '우리동네TV', hasBonus: true },
{ id: '00089', name: '이**', phone: '010-****-5678', channel: 'SNS', hasBonus: false },
{ id: '00103', name: '박**', phone: '010-****-9012', channel: '링고비즈', hasBonus: true },
{ id: '00012', name: '최**', phone: '010-****-3456', channel: 'SNS', hasBonus: false },
{ id: '00067', name: '정**', phone: '010-****-7890', channel: '우리동네TV', hasBonus: false },
{ id: '00025', name: '강**', phone: '010-****-2468', channel: '링고비즈', hasBonus: true },
{ id: '00078', name: '조**', phone: '010-****-1357', channel: 'SNS', hasBonus: false },
],
};
const mockDrawingHistory = [
{ date: '2025-01-15 14:30', winnerCount: 5, isRedraw: false },
{ date: '2025-01-15 14:25', winnerCount: 5, isRedraw: true },
];
interface Winner {
id: string;
name: string;
phone: string;
channel: string;
hasBonus: boolean;
}
export default function DrawPage() {
const params = useParams();
const router = useRouter();
// eventId will be used for API calls in future
const _eventId = params.eventId as string;
// State
const [winnerCount, setWinnerCount] = useState(5);
const [storeBonus, setStoreBonus] = useState(false);
const [isDrawing, setIsDrawing] = useState(false);
const [showResults, setShowResults] = useState(false);
const [winners, setWinners] = useState<Winner[]>([]);
const [animationText, setAnimationText] = useState('추첨 중...');
const [animationSubtext, setAnimationSubtext] = useState('난수 생성 중');
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [redrawDialogOpen, setRedrawDialogOpen] = useState(false);
const [notifyDialogOpen, setNotifyDialogOpen] = useState(false);
const [historyDetailOpen, setHistoryDetailOpen] = useState(false);
const [selectedHistory, setSelectedHistory] = useState<typeof mockDrawingHistory[0] | null>(null);
const handleDecrease = () => {
if (winnerCount > 1) {
setWinnerCount(winnerCount - 1);
}
};
const handleIncrease = () => {
if (winnerCount < 100 && winnerCount < mockEventData.totalParticipants) {
setWinnerCount(winnerCount + 1);
}
};
const handleStartDrawing = () => {
setConfirmDialogOpen(true);
};
const executeDrawing = () => {
setConfirmDialogOpen(false);
setIsDrawing(true);
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
setAnimationText('당첨자 선정 중...');
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
}, 1000);
// Phase 2: 완료 (2 seconds)
setTimeout(() => {
setAnimationText('완료!');
setAnimationSubtext('추첨이 완료되었습니다');
}, 2000);
// Phase 3: Show results (3 seconds)
setTimeout(() => {
setIsDrawing(false);
// Select random winners
const shuffled = [...mockEventData.participants].sort(() => Math.random() - 0.5);
setWinners(shuffled.slice(0, winnerCount));
setShowResults(true);
}, 3000);
};
const handleRedraw = () => {
setRedrawDialogOpen(false);
setShowResults(false);
setWinners([]);
setTimeout(() => {
executeDrawing();
}, 500);
};
const handleNotify = () => {
setNotifyDialogOpen(false);
setTimeout(() => {
alert('알림이 전송되었습니다');
}, 500);
};
const handleDownload = () => {
const now = new Date();
const dateStr = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`;
const filename = `당첨자목록_${mockEventData.name}_${dateStr}.xlsx`;
alert(`${filename} 다운로드를 시작합니다`);
};
const handleBackToEvents = () => {
router.push('/events');
};
const handleHistoryDetail = (history: typeof mockDrawingHistory[0]) => {
setSelectedHistory(history);
setHistoryDetailOpen(true);
};
const getRankClass = (rank: number) => {
if (rank === 1) return 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
if (rank === 2) return 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)';
if (rank === 3) return 'linear-gradient(135deg, #CD7F32 0%, #B87333 100%)';
return '#e0e0e0';
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 4 }}>
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
{/* Setup View (Before Drawing) */}
{!showResults && (
<>
{/* Event Info */}
<Card sx={{ mb: 3, borderRadius: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<EventNote color="error" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{mockEventData.name}</Typography>
</Box>
<Box sx={{ mb: 2 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{mockEventData.totalParticipants}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1"> </Typography>
</Box>
</CardContent>
</Card>
{/* Drawing Settings */}
<Card sx={{ mb: 3, borderRadius: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Tune color="error" />
<Typography variant="h6" sx={{ fontWeight: 700 }}>
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="body2" sx={{ mb: 2 }}>
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 2,
}}
>
<IconButton
onClick={handleDecrease}
sx={{
width: 48,
height: 48,
border: '1px solid',
borderColor: 'divider',
}}
>
<Remove />
</IconButton>
<Typography variant="h3" sx={{ fontWeight: 600, width: 80, textAlign: 'center' }}>
{winnerCount}
</Typography>
<IconButton
onClick={handleIncrease}
sx={{
width: 48,
height: 48,
border: '1px solid',
borderColor: 'divider',
}}
>
<Add />
</IconButton>
</Box>
</Box>
<FormControlLabel
control={
<Checkbox
checked={storeBonus}
onChange={(e) => setStoreBonus(e.target.checked)}
/>
}
label="매장 방문 고객 가산점 (가중치: 1.5배)"
sx={{ mb: 3 }}
/>
<Box sx={{ bgcolor: 'background.default', p: 2, borderRadius: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mb: 1 }}>
<Info sx={{ fontSize: 16 }} color="action" />
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
<Typography variant="body2"> </Typography>
<Typography variant="body2"> </Typography>
</Box>
</CardContent>
</Card>
{/* Drawing Start Button */}
<Button
fullWidth
variant="contained"
size="large"
startIcon={<Casino />}
onClick={handleStartDrawing}
sx={{ mb: 3, py: 1.5, borderRadius: 2, fontWeight: 700, fontSize: '1rem' }}
>
</Button>
{/* Drawing History */}
<Box>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
📜 ( 3)
</Typography>
{mockDrawingHistory.length === 0 ? (
<Card sx={{ borderRadius: 3 }}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" color="text.secondary">
</Typography>
</CardContent>
</Card>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{mockDrawingHistory.slice(0, 3).map((history, index) => (
<Card key={index} sx={{ borderRadius: 2 }}>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="body1" sx={{ mb: 0.5 }}>
{history.date} {history.isRedraw && '(재추첨)'}
</Typography>
<Typography variant="body2" color="text.secondary">
{history.winnerCount}
</Typography>
</Box>
<Button
variant="text"
size="small"
onClick={() => handleHistoryDetail(history)}
startIcon={<ListIcon />}
>
</Button>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</>
)}
{/* Results View (After Drawing) */}
{showResults && (
<>
{/* Results Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
🎉 !
</Typography>
<Typography variant="h6">
{mockEventData.totalParticipants} {winnerCount}
</Typography>
</Box>
{/* Winner List */}
<Box sx={{ mb: 4 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2 }}>
🏆
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
{winners.map((winner, index) => {
const rank = index + 1;
return (
<Card key={winner.id} sx={{ borderRadius: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 48,
height: 48,
borderRadius: '50%',
background: getRankClass(rank),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 600,
fontSize: 18,
}}
>
{rank}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary">
: #{winner.id}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{winner.name} ({winner.phone})
</Typography>
<Typography variant="body2" color="text.secondary">
: {winner.channel}{' '}
{winner.hasBonus && storeBonus && '🌟'}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
);
})}
</Box>
{storeBonus && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center', mt: 2 }}>
🌟
</Typography>
)}
</Box>
{/* Action Buttons */}
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<Download />}
onClick={handleDownload}
sx={{ borderRadius: 2 }}
>
</Button>
</Grid>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<Refresh />}
onClick={() => setRedrawDialogOpen(true)}
sx={{ borderRadius: 2 }}
>
</Button>
</Grid>
</Grid>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<Notifications />}
onClick={() => setNotifyDialogOpen(true)}
sx={{ mb: 2, py: 1.5, borderRadius: 2, fontWeight: 700, fontSize: '1rem' }}
>
</Button>
<Button
fullWidth
variant="text"
size="large"
onClick={handleBackToEvents}
sx={{ borderRadius: 2 }}
>
</Button>
</>
)}
{/* Drawing Animation */}
<Dialog
open={isDrawing}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: 'rgba(0, 0, 0, 0.9)',
color: 'white',
},
}}
>
<DialogContent sx={{ textAlign: 'center', py: 8 }}>
<Casino
sx={{
fontSize: 80,
mb: 3,
animation: 'spin 0.5s infinite',
'@keyframes spin': {
'0%, 100%': { transform: 'rotate(0deg)' },
'50%': { transform: 'rotate(180deg)' },
},
}}
/>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{animationText}
</Typography>
<Typography variant="body1" sx={{ color: 'rgba(255,255,255,0.7)' }}>
{animationSubtext}
</Typography>
</DialogContent>
</Dialog>
{/* Confirm Dialog */}
<Dialog open={confirmDialogOpen} onClose={() => setConfirmDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ textAlign: 'center' }}>
{mockEventData.totalParticipants} {winnerCount} ?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmDialogOpen(false)}></Button>
<Button onClick={executeDrawing} variant="contained">
</Button>
</DialogActions>
</Dialog>
{/* Redraw Dialog */}
<Dialog open={redrawDialogOpen} onClose={() => setRedrawDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2, textAlign: 'center' }}>
.
</Typography>
<Typography variant="body1" sx={{ mb: 2, textAlign: 'center' }}>
?
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setRedrawDialogOpen(false)}></Button>
<Button onClick={handleRedraw} variant="contained">
</Button>
</DialogActions>
</Dialog>
{/* Notify Dialog */}
<Dialog open={notifyDialogOpen} onClose={() => setNotifyDialogOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
<Typography variant="body1" sx={{ mb: 2, textAlign: 'center' }}>
{winnerCount} SMS ?
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', textAlign: 'center' }}>
: {winnerCount * 100} (100/)
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setNotifyDialogOpen(false)}></Button>
<Button onClick={handleNotify} variant="contained">
</Button>
</DialogActions>
</Dialog>
{/* History Detail Dialog */}
<Dialog open={historyDetailOpen} onClose={() => setHistoryDetailOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
{selectedHistory && (
<Box sx={{ p: 2 }}>
<Typography variant="body1" sx={{ mb: 1 }}>
: {selectedHistory.date}
</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
: {selectedHistory.winnerCount}
</Typography>
<Typography variant="body1" sx={{ mb: 2 }}>
: {selectedHistory.isRedraw ? '예' : '아니오'}
</Typography>
<Typography variant="caption" color="text.secondary">
</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setHistoryDetailOpen(false)} variant="contained">
</Button>
</DialogActions>
</Dialog>
</Container>
</Box>
);
}

View File

@ -0,0 +1,505 @@
'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,
} from '@mui/material';
import {
MoreVert,
Group,
Visibility,
TrendingUp,
Share,
CardGiftcard,
HowToReg,
AttachMoney,
People,
Edit,
Download,
Person,
} from '@mui/icons-material';
// 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: ['홈페이지', '카카오톡', 'Instagram'],
participants: 128,
views: 456,
roi: 450,
conversion: 28,
isAIRecommended: true,
};
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시간 전' },
];
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');
// 실시간 업데이트 시뮬레이션
useEffect(() => {
if (event.status === 'active') {
const interval = setInterval(() => {
const increase = Math.floor(Math.random() * 3);
if (increase > 0) {
setEvent((prev) => ({
...prev,
participants: prev.participants + increase,
}));
}
}, 5000);
return () => clearInterval(interval);
}
}, [event.status]);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
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;
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
{/* Event Header */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{event.title}
</Typography>
<IconButton onClick={handleMenuOpen}>
<MoreVert />
</IconButton>
<Menu anchorEl={anchorEl} open={Boolean(anchorEl)} onClose={handleMenuClose}>
<MenuItem onClick={handleMenuClose}>
<Edit sx={{ mr: 1 }} />
</MenuItem>
<MenuItem onClick={handleMenuClose}>
<Share sx={{ mr: 1 }} />
</MenuItem>
<MenuItem onClick={handleMenuClose}>
<Download sx={{ mr: 1 }} />
</MenuItem>
<Divider />
<MenuItem onClick={handleMenuClose} sx={{ color: 'error.main' }}>
</MenuItem>
</Menu>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Chip label={getStatusText(event.status)} color={getStatusColor(event.status) as any} size="small" />
{event.isAIRecommended && (
<Chip
label="AI 추천"
size="small"
sx={{ bgcolor: 'rgba(0, 102, 255, 0.1)', color: 'info.main' }}
/>
)}
</Box>
<Typography variant="body1" color="text.secondary">
{event.startDate} ~ {event.endDate}
</Typography>
</Box>
{/* Real-time KPIs */}
<Box sx={{ mb: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, color: 'success.main' }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'success.main',
}}
/>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
</Typography>
</Box>
</Box>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Group color="primary" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{event.participants}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Visibility color="info" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{event.views}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<TrendingUp color="success" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
ROI
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700, color: 'success.main' }}>
{event.roi}%
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<span className="material-icons" style={{ fontSize: 32, marginBottom: 8, color: '#1976d2' }}>
conversion_path
</span>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
</Typography>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
{event.conversion}%
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
{/* Chart Section */}
<Box sx={{ mb: 5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ p: 3 }}>
<Box sx={{ display: 'flex', gap: 1, mb: 3 }}>
<Button
size="small"
variant={chartPeriod === '7d' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('7d')}
>
7
</Button>
<Button
size="small"
variant={chartPeriod === '30d' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('30d')}
>
30
</Button>
<Button
size="small"
variant={chartPeriod === 'all' ? 'contained' : 'outlined'}
onClick={() => setChartPeriod('all')}
>
</Button>
</Box>
<Box
sx={{
height: 200,
bgcolor: 'grey.100',
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}}
>
<span className="material-icons" style={{ fontSize: 48, color: '#9e9e9e' }}>
show_chart
</span>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
</Typography>
</Box>
</CardContent>
</Card>
</Box>
{/* Event Details */}
<Box sx={{ mb: 5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<CardGiftcard color="error" />
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
</Typography>
<Typography variant="body1">{event.prize}</Typography>
</Box>
</CardContent>
</Card>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<HowToReg color="error" />
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
</Typography>
<Typography variant="body1">{event.method}</Typography>
</Box>
</CardContent>
</Card>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<AttachMoney color="error" />
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
</Typography>
<Typography variant="body1">{event.cost.toLocaleString()}</Typography>
</Box>
</CardContent>
</Card>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ display: 'flex', alignItems: 'flex-start', gap: 2 }}>
<Share color="error" />
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 1 }}>
</Typography>
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 1 }}>
{event.channels.map((channel) => (
<Chip key={channel} label={channel} size="small" color="success" />
))}
</Box>
</Box>
</CardContent>
</Card>
</Box>
</Box>
{/* Quick Actions */}
<Box sx={{ mb: 5 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<Grid container spacing={2}>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
onClick={() => router.push(`/events/${eventId}/participants`)}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<People color="error" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="body2"> </Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Edit color="info" sx={{ fontSize: 32, mb: 1 }} />
<Typography variant="body2"> </Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Share sx={{ fontSize: 32, mb: 1, color: 'text.secondary' }} />
<Typography variant="body2"></Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
>
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Download sx={{ fontSize: 32, mb: 1, color: 'text.secondary' }} />
<Typography variant="body2"> </Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
{/* Recent Participants */}
<Box sx={{ mb: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
</Typography>
<Button
size="small"
endIcon={<span className="material-icons" style={{ fontSize: 16 }}>chevron_right</span>}
onClick={() => router.push(`/events/${eventId}/participants`)}
sx={{ color: 'error.main', fontWeight: 600 }}
>
</Button>
</Box>
<Card elevation={0} sx={{ borderRadius: 3, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)' }}>
<CardContent sx={{ p: 3 }}>
{recentParticipants.map((participant, index) => (
<Box key={index}>
{index > 0 && <Divider sx={{ my: 2 }} />}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: '50%',
bgcolor: 'grey.200',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Person sx={{ color: 'text.secondary' }} />
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{participant.name}
</Typography>
<Typography variant="caption" color="text.secondary">
{participant.phone}
</Typography>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
{participant.time}
</Typography>
</Box>
</Box>
))}
</CardContent>
</Card>
</Box>
</Container>
</Box>
);
}

View File

@ -0,0 +1,409 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
Container,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
Typography,
Button,
InputAdornment,
Pagination,
Chip,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
} from '@mui/material';
import {
Search,
FilterList,
Casino,
Download,
} from '@mui/icons-material';
// Mock 데이터
const mockParticipants = [
{
id: '0001',
name: '김**',
phone: '010-****-1234',
channel: 'SNS (Instagram)',
channelType: 'sns',
date: '2025-11-02 14:23',
status: 'waiting' as const,
},
{
id: '0002',
name: '이**',
phone: '010-****-5678',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 15:45',
status: 'waiting' as const,
},
{
id: '0003',
name: '박**',
phone: '010-****-9012',
channel: '링고비즈',
channelType: 'ringoBiz',
date: '2025-11-02 16:12',
status: 'waiting' as const,
},
{
id: '0004',
name: '최**',
phone: '010-****-3456',
channel: 'SNS (Naver)',
channelType: 'sns',
date: '2025-11-02 17:30',
status: 'winner' as const,
},
{
id: '0005',
name: '정**',
phone: '010-****-7890',
channel: '우리동네TV',
channelType: 'uriTV',
date: '2025-11-02 18:15',
status: 'loser' as const,
},
];
type ChannelType = 'all' | 'uriTV' | 'ringoBiz' | 'sns';
type StatusType = 'all' | 'waiting' | 'winner' | 'loser';
export default function ParticipantsPage() {
const params = useParams();
const router = useRouter();
const eventId = params.eventId as string;
const [searchTerm, setSearchTerm] = useState('');
const [channelFilter, setChannelFilter] = useState<ChannelType>('all');
const [statusFilter, setStatusFilter] = useState<StatusType>('all');
const [currentPage, setCurrentPage] = useState(1);
const [selectedParticipant, setSelectedParticipant] = useState<typeof mockParticipants[0] | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
const itemsPerPage = 20;
// 필터링
const filteredParticipants = mockParticipants.filter((participant) => {
const matchesSearch =
participant.name.includes(searchTerm) || participant.phone.includes(searchTerm);
const matchesChannel = channelFilter === 'all' || participant.channelType === channelFilter;
const matchesStatus = statusFilter === 'all' || participant.status === statusFilter;
return matchesSearch && matchesChannel && matchesStatus;
});
// 페이지네이션
const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, filteredParticipants.length);
const pageParticipants = filteredParticipants.slice(startIndex, endIndex);
const getStatusColor = (status: string) => {
switch (status) {
case 'waiting':
return 'default';
case 'winner':
return 'success';
case 'loser':
return 'error';
default:
return 'default';
}
};
const getStatusText = (status: string) => {
switch (status) {
case 'waiting':
return '당첨 대기';
case 'winner':
return '당첨';
case 'loser':
return '미당첨';
default:
return status;
}
};
const handleParticipantClick = (participant: typeof mockParticipants[0]) => {
setSelectedParticipant(participant);
setDetailDialogOpen(true);
};
const handleDrawClick = () => {
router.push(`/events/${eventId}/draw`);
};
const handleDownloadClick = () => {
alert('엑셀 다운로드 기능은 추후 구현됩니다');
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
{/* Search Section */}
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="이름 또는 전화번호 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
</Box>
{/* Filters */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<FilterList color="error" />
<FormControl sx={{ flex: 1, minWidth: 140 }}>
<InputLabel> </InputLabel>
<Select
value={channelFilter}
label="참여 경로"
onChange={(e) => setChannelFilter(e.target.value as ChannelType)}
>
<MenuItem value="all"> </MenuItem>
<MenuItem value="uriTV">TV</MenuItem>
<MenuItem value="ringoBiz"></MenuItem>
<MenuItem value="sns">SNS</MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flex: 1, minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={statusFilter}
label="상태"
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
>
<MenuItem value="all"></MenuItem>
<MenuItem value="waiting"> </MenuItem>
<MenuItem value="winner"></MenuItem>
<MenuItem value="loser"></MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Total Count & Drawing Button */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 2 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
<span style={{ color: '#e91e63' }}>{filteredParticipants.length}</span>
</Typography>
<Button
variant="contained"
startIcon={<Casino />}
onClick={handleDrawClick}
sx={{ borderRadius: 2 }}
>
</Button>
</Box>
</Box>
{/* Participant List */}
<Box sx={{ mb: 5 }}>
{pageParticipants.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<span className="material-icons" style={{ fontSize: 64, color: '#bdbdbd' }}>
people_outline
</span>
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{pageParticipants.map((participant) => (
<Card
key={participant.id}
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
onClick={() => handleParticipantClick(participant)}
>
<CardContent sx={{ p: 3 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5 }}>
#{participant.id}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 0.5 }}>
{participant.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{participant.phone}
</Typography>
</Box>
<Chip
label={getStatusText(participant.status)}
color={getStatusColor(participant.status) as any}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
{/* Info */}
<Box
sx={{
borderTop: '1px solid',
borderColor: 'divider',
pt: 2,
display: 'flex',
flexDirection: 'column',
gap: 0.5,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{participant.channel}
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
<Typography variant="body2">{participant.date}</Typography>
</Box>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 5 }}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
color="primary"
size="large"
/>
</Box>
)}
{/* Excel Download Button (Desktop only) */}
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<Download />}
onClick={handleDownloadClick}
sx={{ borderRadius: 2 }}
>
</Button>
</Box>
{/* Participant Detail Dialog */}
<Dialog
open={detailDialogOpen}
onClose={() => setDetailDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle> </DialogTitle>
<DialogContent dividers>
{selectedParticipant && (
<Box sx={{ p: 2 }}>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
#{selectedParticipant.id}
</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{selectedParticipant.name}</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{selectedParticipant.phone}</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{selectedParticipant.channel}</Typography>
</Box>
<Box sx={{ mb: 3 }}>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">{selectedParticipant.date}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1">
{getStatusText(selectedParticipant.status)}
</Typography>
</Box>
</Box>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDetailDialogOpen(false)} variant="contained">
</Button>
</DialogActions>
</Dialog>
</Container>
</Box>
);
}

View File

@ -0,0 +1,327 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
Container,
TextField,
Select,
MenuItem,
FormControl,
InputLabel,
Card,
CardContent,
Typography,
Chip,
InputAdornment,
Pagination,
Grid,
} from '@mui/material';
import { Search, FilterList } from '@mui/icons-material';
// Mock 데이터
const mockEvents = [
{
id: '1',
title: '신규고객 유치 이벤트',
status: 'active' as const,
daysLeft: 5,
participants: 128,
roi: 450,
startDate: '2025-11-01',
endDate: '2025-11-15',
prize: '커피 쿠폰',
method: '전화번호 입력',
},
{
id: '2',
title: '재방문 유도 이벤트',
status: 'active' as const,
daysLeft: 12,
participants: 56,
roi: 320,
startDate: '2025-11-05',
endDate: '2025-11-20',
prize: '할인 쿠폰',
method: 'SNS 팔로우',
},
{
id: '3',
title: '매출증대 프로모션',
status: 'ended' as const,
daysLeft: 0,
participants: 234,
roi: 580,
startDate: '2025-10-15',
endDate: '2025-10-31',
prize: '상품권',
method: '구매 인증',
},
{
id: '4',
title: '봄맞이 특별 이벤트',
status: 'scheduled' as const,
daysLeft: 30,
participants: 0,
roi: 0,
startDate: '2025-12-01',
endDate: '2025-12-15',
prize: '체험권',
method: '이메일 등록',
},
];
type EventStatus = 'all' | 'active' | 'scheduled' | 'ended';
type Period = '1month' | '3months' | '6months' | '1year' | 'all';
type SortBy = 'latest' | 'participants' | 'roi';
export default function EventsPage() {
const router = useRouter();
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<EventStatus>('all');
const [periodFilter, setPeriodFilter] = useState<Period>('1month');
const [sortBy, setSortBy] = useState<SortBy>('latest');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// 필터링 및 정렬
const filteredEvents = mockEvents
.filter((event) => {
const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
return matchesSearch && matchesStatus;
})
.sort((a, b) => {
if (sortBy === 'latest') {
return new Date(b.startDate).getTime() - new Date(a.startDate).getTime();
} else if (sortBy === 'participants') {
return b.participants - a.participants;
} else if (sortBy === 'roi') {
return b.roi - a.roi;
}
return 0;
});
// 페이지네이션
const totalPages = Math.ceil(filteredEvents.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, filteredEvents.length);
const pageEvents = filteredEvents.slice(startIndex, endIndex);
const handleEventClick = (eventId: string) => {
router.push(`/events/${eventId}`);
};
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;
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 4, pb: 4, px: { xs: 3, sm: 3, md: 4 } }}>
{/* Search Section */}
<Box sx={{ mb: 3 }}>
<TextField
fullWidth
placeholder="이벤트명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
sx={{
'& .MuiOutlinedInput-root': {
borderRadius: 2,
},
}}
/>
</Box>
{/* Filters */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, flexWrap: 'wrap' }}>
<FilterList color="error" />
<FormControl sx={{ flex: 1, minWidth: 120 }}>
<InputLabel></InputLabel>
<Select
value={statusFilter}
label="상태"
onChange={(e) => setStatusFilter(e.target.value as EventStatus)}
>
<MenuItem value="all"></MenuItem>
<MenuItem value="active"></MenuItem>
<MenuItem value="scheduled"></MenuItem>
<MenuItem value="ended"></MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flex: 1, minWidth: 140 }}>
<InputLabel></InputLabel>
<Select
value={periodFilter}
label="기간"
onChange={(e) => setPeriodFilter(e.target.value as Period)}
>
<MenuItem value="1month"> 1</MenuItem>
<MenuItem value="3months"> 3</MenuItem>
<MenuItem value="6months"> 6</MenuItem>
<MenuItem value="1year"> 1</MenuItem>
<MenuItem value="all"></MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Sorting */}
<Box sx={{ mb: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="body2" color="text.secondary">
:
</Typography>
<FormControl sx={{ width: 160 }}>
<Select
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortBy)}
size="small"
>
<MenuItem value="latest"></MenuItem>
<MenuItem value="participants"></MenuItem>
<MenuItem value="roi"></MenuItem>
</Select>
</FormControl>
</Box>
</Box>
{/* Event List */}
<Box sx={{ mb: 5 }}>
{pageEvents.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<span className="material-icons" style={{ fontSize: 64, color: '#bdbdbd' }}>
event_busy
</span>
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{pageEvents.map((event) => (
<Card
key={event.id}
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 3,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)',
},
}}
onClick={() => handleEventClick(event.id)}
>
<CardContent sx={{ p: 3 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: 2,
}}
>
<Typography variant="h6" sx={{ fontWeight: 700, flex: 1 }}>
{event.title}
</Typography>
<Chip
label={`${getStatusText(event.status)}${
event.status === 'active'
? ` | D-${event.daysLeft}`
: event.status === 'scheduled'
? ` | D+${event.daysLeft}`
: ''
}`}
color={getStatusColor(event.status) as any}
size="small"
sx={{ fontWeight: 600 }}
/>
</Box>
{/* Stats */}
<Grid container spacing={2} sx={{ mb: 2 }}>
<Grid item xs={6}>
<Box>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{event.participants}
</Typography>
</Box>
</Grid>
<Grid item xs={6}>
<Box>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600, color: 'error.main' }}>
{event.roi}%
</Typography>
</Box>
</Grid>
</Grid>
{/* Date */}
<Typography variant="body2" color="text.secondary">
{event.startDate} ~ {event.endDate}
</Typography>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 5 }}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
color="primary"
size="large"
/>
</Box>
)}
</Container>
</Box>
);
}

View File

@ -0,0 +1,378 @@
'use client';
import { useState } from 'react';
import {
Box,
Container,
Card,
CardContent,
Typography,
TextField,
Button,
Checkbox,
FormControlLabel,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Grid,
Divider,
} from '@mui/material';
import { Celebration, CheckCircle } from '@mui/icons-material';
// Mock 데이터
const mockEventData = {
id: '1',
title: '신규고객 유치 이벤트',
prize: '커피 쿠폰',
startDate: '2025-11-01',
endDate: '2025-11-15',
announcementDate: '2025-11-20',
participants: 128,
image: '/images/event-banner.jpg',
};
export default function EventParticipatePage() {
// const params = useParams();
// eventId will be used for API calls in future
// const eventId = params.eventId as string;
// Form state
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [agreedToPrivacy, setAgreedToPrivacy] = useState(false);
// Error state
const [nameError, setNameError] = useState('');
const [phoneError, setPhoneError] = useState('');
// UI state
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
// 전화번호 자동 포맷팅
const formatPhoneNumber = (value: string) => {
const numbers = value.replace(/[^\d]/g, '');
if (numbers.length <= 3) return numbers;
if (numbers.length <= 7) return `${numbers.slice(0, 3)}-${numbers.slice(3)}`;
return `${numbers.slice(0, 3)}-${numbers.slice(3, 7)}-${numbers.slice(7, 11)}`;
};
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatPhoneNumber(e.target.value);
setPhone(formatted);
setPhoneError('');
};
// 유효성 검사
const validateForm = () => {
let isValid = true;
// 이름 검증
if (name.length < 2) {
setNameError('이름은 2자 이상이어야 합니다');
isValid = false;
} else {
setNameError('');
}
// 전화번호 검증
const phonePattern = /^010-\d{4}-\d{4}$/;
if (!phonePattern.test(phone)) {
setPhoneError('올바른 전화번호 형식이 아닙니다 (010-0000-0000)');
isValid = false;
} else {
setPhoneError('');
}
// 개인정보 동의 검증
if (!agreedToPrivacy) {
alert('개인정보 수집 및 이용에 동의해주세요');
isValid = false;
}
return isValid;
};
// 중복 참여 체크
const checkDuplicate = () => {
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
return participatedPhones.includes(phone);
};
// 제출 처리
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validateForm()) {
return;
}
// 중복 참여 체크
if (checkDuplicate()) {
alert('이미 참여하신 전화번호입니다');
return;
}
setSubmitting(true);
// API 호출 시뮬레이션
setTimeout(() => {
// 참여 기록 저장
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
participatedPhones.push(phone);
localStorage.setItem('participated_phones', JSON.stringify(participatedPhones));
setSubmitting(false);
setSuccessDialogOpen(true);
}, 1000);
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', py: 4 }}>
<Container maxWidth="sm">
{/* Event Banner */}
<Card
sx={{
mb: 3,
borderRadius: 3,
overflow: 'hidden',
}}
>
{/* Event Image */}
<Box
sx={{
position: 'relative',
width: '100%',
height: 200,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Celebration sx={{ fontSize: 80, color: 'white', opacity: 0.9 }} />
</Box>
{/* Event Info */}
<CardContent sx={{ textAlign: 'center', py: 3 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 1 }}>
{mockEventData.title}
</Typography>
<Typography variant="body2" color="text.secondary">
!
</Typography>
</CardContent>
</Card>
{/* Event Info */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6}>
<Card sx={{ borderRadius: 2 }}>
<CardContent>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600, mt: 0.5 }}>
{mockEventData.prize}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6}>
<Card sx={{ borderRadius: 2 }}>
<CardContent>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600, mt: 0.5, fontSize: '0.9rem' }}>
{mockEventData.startDate} ~<br />
{mockEventData.endDate}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Participation Form */}
<Card sx={{ borderRadius: 3 }}>
<CardContent sx={{ p: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 3 }}>
</Typography>
<form onSubmit={handleSubmit}>
<TextField
fullWidth
label="이름"
value={name}
onChange={(e) => {
setName(e.target.value);
setNameError('');
}}
error={!!nameError}
helperText={nameError}
sx={{ mb: 2 }}
required
/>
<TextField
fullWidth
label="전화번호"
value={phone}
onChange={handlePhoneChange}
error={!!phoneError}
helperText={phoneError || '010-0000-0000 형식으로 입력해주세요'}
placeholder="010-0000-0000"
sx={{ mb: 3 }}
required
/>
<Divider sx={{ mb: 2 }} />
<FormControlLabel
control={
<Checkbox
checked={agreedToPrivacy}
onChange={(e) => setAgreedToPrivacy(e.target.checked)}
color="primary"
/>
}
label={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography variant="body2"> </Typography>
<Typography
variant="body2"
color="primary"
sx={{
cursor: 'pointer',
textDecoration: 'underline',
fontWeight: 600,
}}
onClick={(e) => {
e.preventDefault();
setPrivacyDialogOpen(true);
}}
>
()
</Typography>
</Box>
}
sx={{ mb: 3 }}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={submitting}
sx={{
py: 1.5,
borderRadius: 2,
fontWeight: 700,
fontSize: '1rem',
}}
>
{submitting ? '참여 중...' : '참여하기'}
</Button>
</form>
</CardContent>
</Card>
{/* Participants Count */}
<Box sx={{ textAlign: 'center', mt: 3 }}>
<Typography variant="body2" color="text.secondary">
<strong style={{ color: '#e91e63' }}>{mockEventData.participants}</strong>
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
: {mockEventData.announcementDate}
</Typography>
</Box>
{/* Privacy Policy Dialog */}
<Dialog
open={privacyDialogOpen}
onClose={() => setPrivacyDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle> </DialogTitle>
<DialogContent dividers>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>1. </strong>
<br />- ,
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>2. </strong>
<br />-
<br />-
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>3. </strong>
<br />- 3
</Typography>
<Typography variant="body2" color="error">
,
.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setPrivacyDialogOpen(false)}></Button>
</DialogActions>
</Dialog>
{/* Success Dialog */}
<Dialog
open={successDialogOpen}
onClose={() => setSuccessDialogOpen(false)}
maxWidth="xs"
fullWidth
>
<DialogContent sx={{ textAlign: 'center', py: 4 }}>
<CheckCircle sx={{ fontSize: 80, color: 'success.main', mb: 2 }} />
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1 }}>
!
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
<strong>{name}</strong>
</Typography>
<Box
sx={{
bgcolor: 'background.default',
p: 2,
borderRadius: 2,
mb: 2,
}}
>
<Typography variant="caption" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{mockEventData.announcementDate}
</Typography>
</Box>
<Typography variant="caption" color="text.secondary">
</Typography>
</DialogContent>
<DialogActions>
<Button
fullWidth
variant="contained"
onClick={() => setSuccessDialogOpen(false)}
>
</Button>
</DialogActions>
</Dialog>
</Container>
</Box>
);
}