mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 05:36:23 +00:00
이벤트 관리 페이지 개발 완료
- 이벤트 목록 페이지 (/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:
parent
526bf06182
commit
6a9dcda398
571
src/app/(main)/events/[eventId]/draw/page.tsx
Normal file
571
src/app/(main)/events/[eventId]/draw/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
505
src/app/(main)/events/[eventId]/page.tsx
Normal file
505
src/app/(main)/events/[eventId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
409
src/app/(main)/events/[eventId]/participants/page.tsx
Normal file
409
src/app/(main)/events/[eventId]/participants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
327
src/app/(main)/events/page.tsx
Normal file
327
src/app/(main)/events/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
378
src/app/events/[eventId]/participate/page.tsx
Normal file
378
src/app/events/[eventId]/participate/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user