doyeon 37ef11740c 추첨 페이지 실제 API 연동 구현
- API 함수 추가: drawWinners, getWinners
- 실제 백엔드 서버(localhost:8084)로 추첨 실행
- 당첨자 목록 실시간 조회 및 표시
- 에러 처리 및 로딩 상태 추가
- 재추첨 기능 API 연동

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-28 15:06:44 +09:00

857 lines
29 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
Container,
Card,
CardContent,
Typography,
Button,
FormControlLabel,
Checkbox,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
IconButton,
Grid,
Alert,
CircularProgress,
} from '@mui/material';
import {
EventNote,
Tune,
Casino,
Download,
Refresh,
Notifications,
Add,
Remove,
Info,
People,
} from '@mui/icons-material';
import { drawWinners, getWinners, getParticipants } from '@/shared/api/participation.api';
import type { DrawWinnersResponse } from '@/shared/types/api.types';
// 디자인 시스템 색상
const colors = {
pink: '#F472B6',
purple: '#C084FC',
purpleLight: '#E9D5FF',
blue: '#60A5FA',
mint: '#34D399',
orange: '#FB923C',
yellow: '#FBBF24',
gray: {
900: '#1A1A1A',
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
},
};
interface Winner {
participantId: string;
name: string;
phoneNumber: string;
rank: number;
channel?: string;
storeVisited: boolean;
}
export default function DrawPage() {
const params = useParams();
const router = useRouter();
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);
// API 관련 상태
const [totalParticipants, setTotalParticipants] = useState(0);
const [eventName] = useState('이벤트');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [drawResult, setDrawResult] = useState<DrawWinnersResponse | null>(null);
// 초기 데이터 로드
useEffect(() => {
const loadInitialData = async () => {
try {
setLoading(true);
setError(null);
// 참여자 총 수 조회
const participantsResponse = await getParticipants({
eventId,
page: 0,
size: 1,
});
setTotalParticipants(participantsResponse.data.totalElements);
// 기존 당첨자가 있는지 확인
try {
const winnersResponse = await getWinners(eventId, 0, 100);
if (winnersResponse.data.content.length > 0) {
// 당첨자가 있으면 결과 화면 표시
const winnerList: Winner[] = winnersResponse.data.content.map((p) => ({
participantId: p.participantId,
name: p.name,
phoneNumber: p.phoneNumber,
rank: 0, // rank는 순서대로
channel: p.channel,
storeVisited: p.storeVisited,
}));
setWinners(winnerList);
setShowResults(true);
}
} catch {
// 당첨자가 없으면 무시
console.log('No winners yet');
}
} catch (err) {
console.error('Failed to load initial data:', err);
setError('데이터를 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
loadInitialData();
}, [eventId]);
const handleDecrease = () => {
if (winnerCount > 1) {
setWinnerCount(winnerCount - 1);
}
};
const handleIncrease = () => {
if (winnerCount < 100 && winnerCount < totalParticipants) {
setWinnerCount(winnerCount + 1);
}
};
const handleStartDrawing = () => {
setConfirmDialogOpen(true);
};
const executeDrawing = async () => {
setConfirmDialogOpen(false);
setIsDrawing(true);
setError(null);
try {
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
setAnimationText('당첨자 선정 중...');
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
}, 1000);
// 실제 API 호출
const response = await drawWinners(eventId, winnerCount, storeBonus);
setDrawResult(response.data);
// Phase 2: 완료 (2 seconds)
setTimeout(() => {
setAnimationText('완료!');
setAnimationSubtext('추첨이 완료되었습니다');
}, 2000);
// Phase 3: 당첨자 목록 변환 및 표시
setTimeout(() => {
const winnerList: Winner[] = response.data.winners.map((w) => ({
participantId: w.participantId,
name: w.name,
phoneNumber: w.phoneNumber,
rank: w.rank,
storeVisited: false, // API 응답에 포함되지 않음
}));
setWinners(winnerList);
setIsDrawing(false);
setShowResults(true);
}, 3000);
} catch (err) {
console.error('Draw failed:', err);
setIsDrawing(false);
const errorMessage =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
setError(errorMessage || '추첨에 실패했습니다. 다시 시도해주세요.');
}
};
const handleRedraw = async () => {
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 = `당첨자목록_${eventName}_${dateStr}.xlsx`;
alert(`${filename} 다운로드를 시작합니다`);
};
const handleBackToEvents = () => {
router.push('/events');
};
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';
};
// 로딩 상태
if (loading) {
return (
<Box
sx={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<CircularProgress size={60} />
</Box>
);
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 10 } }}>
{/* 에러 메시지 */}
{error && (
<Alert severity="error" sx={{ mb: 4, borderRadius: 3 }} onClose={() => setError(null)}>
{error}
</Alert>
)}
{/* Setup View (Before Drawing) */}
{!showResults && (
<>
{/* Page Header */}
<Box sx={{ mb: 8 }}>
<Typography variant="h4" sx={{ fontWeight: 700, fontSize: '2rem', mb: 2 }}>
🎲
</Typography>
<Typography variant="body1" color="text.secondary">
</Typography>
</Box>
{/* Event Info Summary Cards */}
<Grid container spacing={6} sx={{ mb: 10 }}>
<Grid item xs={6} md={6}>
<Card
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.purpleLight} 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<EventNote sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
{eventName}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={6}>
<Card
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
background: `linear-gradient(135deg, ${colors.blue} 0%, #93C5FD 100%)`,
}}
>
<CardContent sx={{ textAlign: 'center', py: 6, px: 4 }}>
<People sx={{ fontSize: 40, mb: 2, color: 'white' }} />
<Typography variant="caption" display="block" sx={{ mb: 2, color: 'rgba(255,255,255,0.9)' }}>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{totalParticipants}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Drawing Settings */}
<Card elevation={0} sx={{ mb: 10, borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
<Tune sx={{ fontSize: 32, color: colors.pink }} />
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
</Typography>
</Box>
<Box sx={{ mb: 6 }}>
<Typography variant="h6" sx={{ mb: 4, fontWeight: 600 }}>
</Typography>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
p: 4,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<IconButton
onClick={handleDecrease}
sx={{
width: 60,
height: 60,
border: '2px solid',
borderColor: colors.purple,
color: colors.purple,
'&:hover': {
bgcolor: colors.purpleLight,
},
}}
>
<Remove sx={{ fontSize: 28 }} />
</IconButton>
<Typography variant="h2" sx={{ fontWeight: 700, width: 120, textAlign: 'center', color: colors.purple }}>
{winnerCount}
</Typography>
<IconButton
onClick={handleIncrease}
sx={{
width: 60,
height: 60,
border: '2px solid',
borderColor: colors.purple,
color: colors.purple,
'&:hover': {
bgcolor: colors.purpleLight,
},
}}
>
<Add sx={{ fontSize: 28 }} />
</IconButton>
</Box>
</Box>
<FormControlLabel
control={
<Checkbox
checked={storeBonus}
onChange={(e) => setStoreBonus(e.target.checked)}
sx={{
color: colors.purple,
'&.Mui-checked': {
color: colors.purple,
},
}}
/>
}
label={
<Typography variant="body1" sx={{ fontWeight: 600 }}>
(가중치: 1.5배)
</Typography>
}
sx={{ mb: 6 }}
/>
<Box
sx={{
bgcolor: colors.purpleLight,
p: 4,
borderRadius: 3,
border: `1px solid ${colors.purple}40`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, mb: 3 }}>
<Info sx={{ fontSize: 24, color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 600, color: colors.purple }}>
</Typography>
</Box>
<Typography variant="body1" sx={{ mb: 2, color: colors.gray[700] }}>
</Typography>
<Typography variant="body1" sx={{ color: colors.gray[700] }}>
</Typography>
</Box>
</CardContent>
</Card>
{/* Drawing Start Button */}
<Button
fullWidth
variant="contained"
size="large"
startIcon={<Casino sx={{ fontSize: 28 }} />}
onClick={handleStartDrawing}
sx={{
mb: 10,
py: 3,
borderRadius: 4,
fontWeight: 700,
fontSize: '1.25rem',
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
'&:hover': {
background: `linear-gradient(135deg, ${colors.pink} 0%, ${colors.purple} 100%)`,
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.2)',
transform: 'translateY(-2px)',
},
}}
>
</Button>
</>
)}
{/* Results View (After Drawing) */}
{showResults && (
<>
{/* Results Header */}
<Box sx={{ textAlign: 'center', mb: 10 }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 4, fontSize: '2rem' }}>
🎉 !
</Typography>
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
{totalParticipants} {winners.length}
</Typography>
{drawResult && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
: {new Date(drawResult.drawnAt).toLocaleString('ko-KR')}
</Typography>
)}
</Box>
{/* Winner List */}
<Box sx={{ mb: 10 }}>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
🏆
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{winners.map((winner) => {
return (
<Card
key={winner.participantId}
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: 5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Box
sx={{
width: 64,
height: 64,
borderRadius: '50%',
background: getRankClass(winner.rank),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 700,
fontSize: 20,
flexShrink: 0,
}}
>
{winner.rank}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
ID: {winner.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
{winner.name} ({winner.phoneNumber})
</Typography>
{winner.channel && (
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
: {winner.channel}{' '}
{winner.storeVisited && storeBonus && '🌟'}
</Typography>
)}
</Box>
</Box>
</CardContent>
</Card>
);
})}
</Box>
{storeBonus && (
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', textAlign: 'center', mt: 6, fontSize: '0.875rem' }}
>
🌟
</Typography>
)}
</Box>
{/* Action Buttons */}
<Grid container spacing={4} sx={{ mb: 4 }}>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<Download />}
onClick={handleDownload}
sx={{
borderRadius: 3,
py: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
</Grid>
<Grid item xs={6}>
<Button
fullWidth
variant="outlined"
size="large"
startIcon={<Refresh />}
onClick={() => setRedrawDialogOpen(true)}
sx={{
borderRadius: 3,
py: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
</Grid>
</Grid>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<Notifications />}
onClick={() => setNotifyDialogOpen(true)}
sx={{
mb: 4,
py: 3,
borderRadius: 3,
fontWeight: 700,
fontSize: '1rem',
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
<Button
fullWidth
variant="text"
size="large"
onClick={handleBackToEvents}
sx={{
borderRadius: 3,
py: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
</>
)}
{/* Drawing Animation */}
<Dialog
open={isDrawing}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: 'background.paper',
borderRadius: 4,
},
}}
>
<DialogContent sx={{ textAlign: 'center', py: 16 }}>
{/* 그라데이션 스피너 */}
<Box
sx={{
width: 100,
height: 100,
margin: '0 auto 48px',
borderRadius: '50%',
background: `conic-gradient(from 0deg, ${colors.purple}, ${colors.pink}, ${colors.blue}, ${colors.purple})`,
animation: 'spin 1.5s linear infinite',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
'&::before': {
content: '""',
position: 'absolute',
width: 75,
height: 75,
borderRadius: '50%',
backgroundColor: 'background.paper',
},
}}
>
<Casino
sx={{
fontSize: 50,
color: colors.purple,
zIndex: 1,
animation: 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.7, transform: 'scale(0.95)' },
},
}}
/>
</Box>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2, fontSize: '2rem' }}>
{animationText}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ fontSize: '1.125rem' }}>
{animationSubtext}
</Typography>
</DialogContent>
</Dialog>
{/* Confirm Dialog */}
<Dialog
open={confirmDialogOpen}
onClose={() => setConfirmDialogOpen(false)}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { borderRadius: 4 } }}
>
<DialogTitle sx={{ pt: 6, px: 6, pb: 4, fontSize: '1.5rem', fontWeight: 700 }}>
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
<Typography variant="body1" sx={{ textAlign: 'center', fontSize: '1.125rem' }}>
{totalParticipants} {winnerCount} ?
</Typography>
{storeBonus && (
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
1.5 .
</Typography>
)}
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
<Button
onClick={() => setConfirmDialogOpen(false)}
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
<Button
onClick={executeDrawing}
variant="contained"
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
</DialogActions>
</Dialog>
{/* Redraw Dialog */}
<Dialog
open={redrawDialogOpen}
onClose={() => setRedrawDialogOpen(false)}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { borderRadius: 4 } }}
>
<DialogTitle sx={{ pt: 6, px: 6, pb: 4, fontSize: '1.5rem', fontWeight: 700 }}>
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
<Typography variant="body1" sx={{ mb: 4, textAlign: 'center', fontSize: '1.125rem' }}>
.
</Typography>
<Typography variant="body1" sx={{ mb: 4, textAlign: 'center', fontSize: '1.125rem' }}>
?
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', textAlign: 'center', fontSize: '0.875rem' }}
>
</Typography>
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
<Button
onClick={() => setRedrawDialogOpen(false)}
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
<Button
onClick={handleRedraw}
variant="contained"
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
</DialogActions>
</Dialog>
{/* Notify Dialog */}
<Dialog
open={notifyDialogOpen}
onClose={() => setNotifyDialogOpen(false)}
maxWidth="xs"
fullWidth
PaperProps={{ sx: { borderRadius: 4 } }}
>
<DialogTitle sx={{ pt: 6, px: 6, pb: 4, fontSize: '1.5rem', fontWeight: 700 }}>
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
<Typography variant="body1" sx={{ mb: 4, textAlign: 'center', fontSize: '1.125rem' }}>
{winnerCount} SMS ?
</Typography>
<Typography
variant="caption"
color="text.secondary"
sx={{ display: 'block', textAlign: 'center', fontSize: '0.875rem' }}
>
: {winnerCount * 100} (100/)
</Typography>
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
<Button
onClick={() => setNotifyDialogOpen(false)}
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
}}
>
</Button>
<Button
onClick={handleNotify}
variant="contained"
sx={{
flex: 1,
py: 2,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
}}
>
</Button>
</DialogActions>
</Dialog>
</Container>
</Box>
);
}