Merge pull request #6 from ktds-dg0501/partici

추첨 페이지 실제 API 연동 구현
This commit is contained in:
kkkd-max 2025-10-28 15:09:50 +09:00 committed by GitHub
commit bace9476b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 1018 additions and 519 deletions

View File

@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(curl:*)",
"Bash(cat:*)",
"Bash(mkdir:*)",
"Bash(npm install:*)",
"Bash(npm run build:*)",
"Bash(npm run dev:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(ls:*)",
"Bash(git add:*)"
],
"deny": [],
"ask": []
}
}

View File

@ -12,6 +12,15 @@ const nextConfig = {
env: {
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
},
// CORS 우회를 위한 API Proxy 설정
async rewrites() {
return [
{
source: '/api/proxy/:path*',
destination: 'http://localhost:8084/api/:path*',
},
]
},
}
module.exports = nextConfig

8
package-lock.json generated
View File

@ -17,7 +17,7 @@
"@tanstack/react-query": "^5.59.16",
"@use-funnel/browser": "^0.0.12",
"@use-funnel/next": "^0.0.12",
"axios": "^1.7.7",
"axios": "^1.13.0",
"chart.js": "^4.5.1",
"dayjs": "^1.11.13",
"next": "^14.2.15",
@ -2122,9 +2122,9 @@
}
},
"node_modules/axios": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
"version": "1.13.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",

View File

@ -19,7 +19,7 @@
"@tanstack/react-query": "^5.59.16",
"@use-funnel/browser": "^0.0.12",
"@use-funnel/next": "^0.0.12",
"axios": "^1.7.7",
"axios": "^1.13.0",
"chart.js": "^4.5.1",
"dayjs": "^1.11.13",
"next": "^14.2.15",

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
@ -17,6 +17,8 @@ import {
DialogActions,
IconButton,
Grid,
Alert,
CircularProgress,
} from '@mui/material';
import {
EventNote,
@ -25,12 +27,13 @@ import {
Download,
Refresh,
Notifications,
List as ListIcon,
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 = {
@ -50,39 +53,19 @@ const colors = {
},
};
// 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;
participantId: string;
name: string;
phone: string;
channel: string;
hasBonus: boolean;
phoneNumber: string;
rank: number;
channel?: string;
storeVisited: 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;
const eventId = params.eventId as string;
// State
const [winnerCount, setWinnerCount] = useState(5);
@ -95,8 +78,60 @@ export default function DrawPage() {
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);
// 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) {
@ -105,7 +140,7 @@ export default function DrawPage() {
};
const handleIncrease = () => {
if (winnerCount < 100 && winnerCount < mockEventData.totalParticipants) {
if (winnerCount < 100 && winnerCount < totalParticipants) {
setWinnerCount(winnerCount + 1);
}
};
@ -114,34 +149,54 @@ export default function DrawPage() {
setConfirmDialogOpen(true);
};
const executeDrawing = () => {
const executeDrawing = async () => {
setConfirmDialogOpen(false);
setIsDrawing(true);
setError(null);
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
setAnimationText('당첨자 선정 중...');
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
}, 1000);
try {
// Phase 1: 난수 생성 중 (1 second)
setTimeout(() => {
setAnimationText('당첨자 선정 중...');
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
}, 1000);
// Phase 2: 완료 (2 seconds)
setTimeout(() => {
setAnimationText('완료!');
setAnimationSubtext('추첨이 완료되었습니다');
}, 2000);
// 실제 API 호출
const response = await drawWinners(eventId, winnerCount, storeBonus);
setDrawResult(response.data);
// Phase 3: Show results (3 seconds)
setTimeout(() => {
// 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);
// Select random winners
const shuffled = [...mockEventData.participants].sort(() => Math.random() - 0.5);
setWinners(shuffled.slice(0, winnerCount));
setShowResults(true);
}, 3000);
const errorMessage =
err && typeof err === 'object' && 'response' in err
? (err as { response?: { data?: { message?: string } } }).response?.data?.message
: undefined;
setError(errorMessage || '추첨에 실패했습니다. 다시 시도해주세요.');
}
};
const handleRedraw = () => {
const handleRedraw = async () => {
setRedrawDialogOpen(false);
setShowResults(false);
setWinners([]);
@ -161,7 +216,7 @@ export default function DrawPage() {
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`;
const filename = `당첨자목록_${eventName}_${dateStr}.xlsx`;
alert(`${filename} 다운로드를 시작합니다`);
};
@ -169,11 +224,6 @@ export default function DrawPage() {
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%)';
@ -181,9 +231,32 @@ export default function DrawPage() {
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 && (
<>
@ -214,7 +287,7 @@ export default function DrawPage() {
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
{mockEventData.name}
{eventName}
</Typography>
</CardContent>
</Card>
@ -234,7 +307,7 @@ export default function DrawPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{mockEventData.totalParticipants}
{totalParticipants}
</Typography>
</CardContent>
</Card>
@ -372,65 +445,6 @@ export default function DrawPage() {
</Button>
{/* Drawing History */}
<Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 6, fontSize: '1.5rem' }}>
📜
</Typography>
{mockDrawingHistory.length === 0 ? (
<Card elevation={0} sx={{ borderRadius: 4, boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)' }}>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<ListIcon sx={{ fontSize: 64, color: colors.gray[300], mb: 2 }} />
<Typography variant="h6" color="text.secondary">
</Typography>
</CardContent>
</Card>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{mockDrawingHistory.slice(0, 3).map((history, index) => (
<Card
key={index}
elevation={0}
sx={{
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: 5 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 700 }}>
{history.date} {history.isRedraw && '(재추첨)'}
</Typography>
<Typography variant="body1" color="text.secondary">
{history.winnerCount}
</Typography>
</Box>
<Button
variant="outlined"
size="medium"
onClick={() => handleHistoryDetail(history)}
startIcon={<ListIcon />}
sx={{
borderRadius: 3,
borderColor: colors.purple,
color: colors.purple,
'&:hover': {
borderColor: colors.purple,
bgcolor: colors.purpleLight,
},
}}
>
</Button>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
</>
)}
@ -443,8 +457,13 @@ export default function DrawPage() {
🎉 !
</Typography>
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
{mockEventData.totalParticipants} {winnerCount}
{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 */}
@ -453,11 +472,10 @@ export default function DrawPage() {
🏆
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{winners.map((winner, index) => {
const rank = index + 1;
{winners.map((winner) => {
return (
<Card
key={winner.id}
key={winner.participantId}
elevation={0}
sx={{
borderRadius: 4,
@ -471,7 +489,7 @@ export default function DrawPage() {
width: 64,
height: 64,
borderRadius: '50%',
background: getRankClass(rank),
background: getRankClass(winner.rank),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@ -481,19 +499,21 @@ export default function DrawPage() {
flexShrink: 0,
}}
>
{rank}
{winner.rank}
</Box>
<Box sx={{ flex: 1 }}>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
: #{winner.id}
ID: {winner.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
{winner.name} ({winner.phone})
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
: {winner.channel}{' '}
{winner.hasBonus && storeBonus && '🌟'}
{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>
@ -671,8 +691,13 @@ export default function DrawPage() {
</DialogTitle>
<DialogContent sx={{ px: 6, pb: 4 }}>
<Typography variant="body1" sx={{ textAlign: 'center', fontSize: '1.125rem' }}>
{mockEventData.totalParticipants} {winnerCount} ?
{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
@ -825,56 +850,6 @@ export default function DrawPage() {
</DialogActions>
</Dialog>
{/* History Detail Dialog */}
<Dialog
open={historyDetailOpen}
onClose={() => setHistoryDetailOpen(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 }}>
{selectedHistory && (
<Box sx={{ p: 4 }}>
<Typography variant="body1" sx={{ mb: 3, fontSize: '1.125rem' }}>
: {selectedHistory.date}
</Typography>
<Typography variant="body1" sx={{ mb: 3, fontSize: '1.125rem' }}>
: {selectedHistory.winnerCount}
</Typography>
<Typography variant="body1" sx={{ mb: 4, fontSize: '1.125rem' }}>
: {selectedHistory.isRedraw ? '예' : '아니오'}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 6, pb: 6, pt: 4 }}>
<Button
onClick={() => setHistoryDetailOpen(false)}
variant="contained"
fullWidth
sx={{
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>
);

View File

@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import {
Box,
@ -22,6 +22,8 @@ import {
DialogContent,
DialogActions,
Grid,
CircularProgress,
Alert,
} from '@mui/material';
import {
Search,
@ -32,7 +34,10 @@ import {
TrendingUp,
Person,
AccessTime,
Error as ErrorIcon,
} from '@mui/icons-material';
import { getParticipants } from '@/shared/api/participation.api';
import type { ParticipationResponse } from '@/shared/types/api.types';
// 디자인 시스템 색상
const colors = {
@ -52,115 +57,99 @@ const colors = {
},
};
// 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';
type StatusType = 'all' | 'winner' | 'waiting';
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 [participants, setParticipants] = useState<ParticipationResponse[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// 필터 상태
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<StatusType>('all');
const [storeVisitedFilter, setStoreVisitedFilter] = useState<boolean | undefined>(undefined);
// 페이지네이션 상태
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [totalElements, setTotalElements] = useState(0);
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;
// UI 상태
const [selectedParticipant, setSelectedParticipant] = useState<ParticipationResponse | null>(null);
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
return matchesSearch && matchesChannel && matchesStatus;
// 데이터 로드
const loadParticipants = async () => {
try {
setLoading(true);
setError('');
const response = await getParticipants({
eventId,
storeVisited: storeVisitedFilter,
page: currentPage - 1, // API는 0부터 시작
size: itemsPerPage,
sort: ['createdAt,DESC'],
});
if (response.success && response.data) {
setParticipants(response.data.content);
setTotalPages(response.data.totalPages);
setTotalElements(response.data.totalElements);
} else {
setError(response.message || '참여자 목록을 불러오는데 실패했습니다.');
}
} catch (err: any) {
console.error('참여자 목록 로드 오류:', err);
setError(err.response?.data?.message || '참여자 목록을 불러오는데 실패했습니다.');
} finally {
setLoading(false);
}
};
// 초기 로드 및 필터 변경 시 재로드
useEffect(() => {
loadParticipants();
}, [eventId, currentPage, storeVisitedFilter]);
// 클라이언트 사이드 필터링 (검색어, 당첨 여부)
const filteredParticipants = participants.filter((participant) => {
const matchesSearch =
searchTerm === '' ||
participant.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
participant.phoneNumber.includes(searchTerm) ||
participant.email?.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus =
statusFilter === 'all' ||
(statusFilter === 'winner' && participant.isWinner) ||
(statusFilter === 'waiting' && !participant.isWinner);
return matchesSearch && 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 stats = {
total: totalElements,
waiting: participants.filter((p) => !p.isWinner).length,
winner: participants.filter((p) => p.isWinner).length,
};
const getStatusText = (status: string) => {
switch (status) {
case 'waiting':
return '당첨 대기';
case 'winner':
return '당첨';
case 'loser':
return '미당첨';
default:
return status;
}
const getStatusText = (isWinner: boolean) => {
return isWinner ? '당첨' : '당첨 대기';
};
const handleParticipantClick = (participant: typeof mockParticipants[0]) => {
const getStatusColor = (isWinner: boolean) => {
return isWinner ? 'success' : 'default';
};
const handleParticipantClick = (participant: ParticipationResponse) => {
setSelectedParticipant(participant);
setDetailDialogOpen(true);
};
@ -173,16 +162,9 @@ export default function ParticipantsPage() {
alert('엑셀 다운로드 기능은 추후 구현됩니다');
};
// 통계 계산
const stats = {
total: mockParticipants.length,
waiting: mockParticipants.filter((p) => p.status === 'waiting').length,
winner: mockParticipants.filter((p) => p.status === 'winner').length,
channelDistribution: {
uriTV: mockParticipants.filter((p) => p.channelType === 'uriTV').length,
ringoBiz: mockParticipants.filter((p) => p.channelType === 'ringoBiz').length,
sns: mockParticipants.filter((p) => p.channelType === 'sns').length,
},
const handlePageChange = (_: React.ChangeEvent<unknown>, page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
return (
@ -198,9 +180,16 @@ export default function ParticipantsPage() {
</Typography>
</Box>
{/* Error Alert */}
{error && (
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
{error}
</Alert>
)}
{/* Statistics Cards */}
<Grid container spacing={6} sx={{ mb: 10 }}>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -215,12 +204,12 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.total}
{loading ? '...' : stats.total}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -235,12 +224,12 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.waiting}
{loading ? '...' : stats.waiting}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<Grid item xs={6} md={4}>
<Card
elevation={0}
sx={{
@ -255,27 +244,7 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
{stats.winner}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={6} md={3}>
<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 }}>
<Casino 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' }}>
{stats.total > 0 ? Math.round((stats.winner / stats.total) * 100) : 0}%
{loading ? '...' : stats.winner}
</Typography>
</CardContent>
</Card>
@ -286,7 +255,7 @@ export default function ParticipantsPage() {
<Box sx={{ mb: 6 }}>
<TextField
fullWidth
placeholder="이름 또는 전화번호 검색..."
placeholder="이름, 전화번호 또는 이메일 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
@ -302,6 +271,7 @@ export default function ParticipantsPage() {
bgcolor: 'white',
},
}}
disabled={loading}
/>
</Box>
@ -310,17 +280,23 @@ export default function ParticipantsPage() {
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
<FormControl sx={{ flex: 1, minWidth: 160 }}>
<InputLabel> </InputLabel>
<InputLabel> </InputLabel>
<Select
value={channelFilter}
label="참여 경로"
onChange={(e) => setChannelFilter(e.target.value as ChannelType)}
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
label="매장 방문"
onChange={(e) => {
const value = e.target.value;
setStoreVisitedFilter(
value === 'all' ? undefined : value === 'visited' ? true : false
);
setCurrentPage(1); // 필터 변경 시 첫 페이지로
}}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all"> </MenuItem>
<MenuItem value="uriTV">TV</MenuItem>
<MenuItem value="ringoBiz"></MenuItem>
<MenuItem value="sns">SNS</MenuItem>
<MenuItem value="all"></MenuItem>
<MenuItem value="visited"></MenuItem>
<MenuItem value="not_visited"></MenuItem>
</Select>
</FormControl>
<FormControl sx={{ flex: 1, minWidth: 140 }}>
@ -330,11 +306,11 @@ export default function ParticipantsPage() {
label="상태"
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
sx={{ borderRadius: 2 }}
disabled={loading}
>
<MenuItem value="all"></MenuItem>
<MenuItem value="waiting"> </MenuItem>
<MenuItem value="winner"></MenuItem>
<MenuItem value="loser"></MenuItem>
</Select>
</FormControl>
</Box>
@ -344,13 +320,14 @@ export default function ParticipantsPage() {
<Box sx={{ mb: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
<span style={{ color: colors.pink }}>{filteredParticipants.length}</span>
<span style={{ color: colors.pink }}>{filteredParticipants.length}</span>
</Typography>
<Box sx={{ display: 'flex', gap: 3 }}>
<Button
variant="outlined"
startIcon={<Download />}
onClick={handleDownloadClick}
disabled={loading}
sx={{
borderRadius: 3,
px: 4,
@ -369,6 +346,7 @@ export default function ParticipantsPage() {
variant="contained"
startIcon={<Casino />}
onClick={handleDrawClick}
disabled={loading}
sx={{
borderRadius: 3,
px: 4,
@ -386,138 +364,164 @@ export default function ParticipantsPage() {
</Box>
</Box>
{/* Participant List */}
<Box sx={{ mb: 10 }}>
{pageParticipants.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 16 }}>
<Person sx={{ fontSize: 80, color: colors.gray[300], mb: 3 }} />
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
</Typography>
</Box>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{pageParticipants.map((participant) => (
<Card
key={participant.id}
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-4px)',
},
}}
onClick={() => handleParticipantClick(participant)}
>
<CardContent sx={{ p: 5 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: 4,
}}
>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: colors.purpleLight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Person sx={{ fontSize: 32, color: colors.purple }} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
#{participant.id}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
{participant.name}
</Typography>
<Typography variant="body1" color="text.secondary">
{participant.phone}
</Typography>
</Box>
</Box>
<Chip
label={getStatusText(participant.status)}
color={getStatusColor(participant.status) as any}
size="medium"
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
/>
</Box>
{/* Loading State */}
{loading && (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 16 }}>
<CircularProgress size={60} />
</Box>
)}
{/* Info */}
<Box
sx={{
borderTop: '1px solid',
borderColor: colors.gray[100],
pt: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
{/* Empty State */}
{!loading && filteredParticipants.length === 0 && (
<Box sx={{ textAlign: 'center', py: 16 }}>
<Person sx={{ fontSize: 80, color: colors.gray[300], mb: 3 }} />
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
? '검색 결과가 없습니다'
: '아직 참여자가 없습니다'}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
? '다른 검색어나 필터를 사용해보세요'
: '첫 번째 참여자를 기다리고 있습니다'}
</Typography>
</Box>
)}
{/* Participant List */}
{!loading && filteredParticipants.length > 0 && (
<>
<Box sx={{ mb: 10 }}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
{filteredParticipants.map((participant) => (
<Card
key={participant.participantId}
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.2s ease',
'&:hover': {
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-4px)',
},
}}
onClick={() => handleParticipantClick(participant)}
>
<CardContent sx={{ p: 5 }}>
{/* Header */}
<Box
sx={{
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between',
mb: 4,
}}
>
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', gap: 3 }}>
<Box
sx={{
width: 56,
height: 56,
borderRadius: '50%',
bgcolor: colors.purpleLight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Person sx={{ fontSize: 32, color: colors.purple }} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
#{participant.participantId}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
{participant.name}
</Typography>
<Typography variant="body1" color="text.secondary">
{participant.phoneNumber}
</Typography>
</Box>
</Box>
<Chip
label={participant.channel}
size="small"
sx={{
bgcolor: colors.purpleLight,
color: colors.purple,
fontWeight: 600,
}}
label={getStatusText(participant.isWinner)}
color={getStatusColor(participant.isWinner) as any}
size="medium"
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
/>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{participant.date}
</Typography>
</Box>
</Box>
</CardContent>
</Card>
))}
</Box>
)}
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 10 }}>
<Pagination
count={totalPages}
page={currentPage}
onChange={(_, page) => setCurrentPage(page)}
color="primary"
size="large"
sx={{
'& .MuiPaginationItem-root': {
fontSize: '1rem',
fontWeight: 600,
},
}}
/>
</Box>
{/* Info */}
<Box
sx={{
borderTop: '1px solid',
borderColor: colors.gray[100],
pt: 4,
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{participant.channel && (
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Chip
label={participant.channel}
size="small"
sx={{
bgcolor: colors.purpleLight,
color: colors.purple,
fontWeight: 600,
}}
/>
</Box>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
</Typography>
</Box>
{participant.storeVisited && (
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
<Typography variant="body1" color="text.secondary">
</Typography>
<Chip label="방문" size="small" color="success" />
</Box>
)}
</Box>
</CardContent>
</Card>
))}
</Box>
</Box>
{/* Pagination */}
{totalPages > 1 && (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 10 }}>
<Pagination
count={totalPages}
page={currentPage}
onChange={handlePageChange}
color="primary"
size="large"
sx={{
'& .MuiPaginationItem-root': {
fontSize: '1rem',
fontWeight: 600,
},
}}
/>
</Box>
)}
</>
)}
{/* Participant Detail Dialog */}
@ -560,7 +564,7 @@ export default function ParticipantsPage() {
{selectedParticipant.name}
</Typography>
<Typography variant="body2" color="text.secondary">
#{selectedParticipant.id}
#{selectedParticipant.participantId}
</Typography>
</Box>
@ -576,24 +580,43 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.phone}
{selectedParticipant.phoneNumber}
</Typography>
</Box>
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.channel}
</Typography>
</Box>
{selectedParticipant.email && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.email}
</Typography>
</Box>
)}
{selectedParticipant.channel && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.channel}
</Typography>
</Box>
)}
<Box
sx={{
@ -606,7 +629,7 @@ export default function ParticipantsPage() {
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.date}
{new Date(selectedParticipant.participatedAt).toLocaleString('ko-KR')}
</Typography>
</Box>
@ -621,12 +644,44 @@ export default function ParticipantsPage() {
</Typography>
<Chip
label={getStatusText(selectedParticipant.status)}
color={getStatusColor(selectedParticipant.status) as any}
label={getStatusText(selectedParticipant.isWinner)}
color={getStatusColor(selectedParticipant.isWinner) as any}
size="medium"
sx={{ fontWeight: 600 }}
/>
</Box>
{selectedParticipant.storeVisited && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Chip label="방문 완료" color="success" sx={{ fontWeight: 600 }} />
</Box>
)}
{selectedParticipant.bonusEntries > 0 && (
<Box
sx={{
p: 3,
bgcolor: colors.gray[100],
borderRadius: 3,
}}
>
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
</Typography>
<Typography variant="body1" sx={{ fontWeight: 600 }}>
{selectedParticipant.bonusEntries}
</Typography>
</Box>
)}
</Box>
</Box>
)}

View File

@ -1,6 +1,7 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import {
Box,
Container,
@ -17,34 +18,36 @@ import {
DialogActions,
Grid,
Divider,
Alert,
CircularProgress,
} from '@mui/material';
import { Celebration, CheckCircle } from '@mui/icons-material';
import { Celebration, CheckCircle, Error as ErrorIcon } from '@mui/icons-material';
import { participate } from '@/shared/api/participation.api';
import type { ParticipationRequest } from '@/shared/types/api.types';
// Mock 데이터
// Mock 데이터 (이벤트 정보는 추후 Event API로 대체)
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;
const params = useParams();
const eventId = params.eventId as string;
// Form state
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [agreedToPrivacy, setAgreedToPrivacy] = useState(false);
// Error state
const [nameError, setNameError] = useState('');
const [phoneError, setPhoneError] = useState('');
const [apiError, setApiError] = useState('');
// UI state
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
@ -63,21 +66,22 @@ export default function EventParticipatePage() {
const formatted = formatPhoneNumber(e.target.value);
setPhone(formatted);
setPhoneError('');
setApiError('');
};
// 유효성 검사
const validateForm = () => {
let isValid = true;
// 이름 검증
if (name.length < 2) {
setNameError('이름은 2자 이상이어야 합니다');
// 이름 검증 (2-50자)
if (name.length < 2 || name.length > 50) {
setNameError('이름은 2자 이상 50자 이하이어야 합니다');
isValid = false;
} else {
setNameError('');
}
// 전화번호 검증
// 전화번호 검증 (패턴: ^\d{3}-\d{3,4}-\d{4}$)
const phonePattern = /^010-\d{4}-\d{4}$/;
if (!phonePattern.test(phone)) {
setPhoneError('올바른 전화번호 형식이 아닙니다 (010-0000-0000)');
@ -95,14 +99,6 @@ export default function EventParticipatePage() {
return isValid;
};
// 중복 참여 체크
const checkDuplicate = () => {
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
return participatedPhones.includes(phone);
};
// 제출 처리
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@ -111,26 +107,48 @@ export default function EventParticipatePage() {
return;
}
// 중복 참여 체크
if (checkDuplicate()) {
alert('이미 참여하신 전화번호입니다');
return;
}
setSubmitting(true);
setApiError('');
// API 호출 시뮬레이션
setTimeout(() => {
// 참여 기록 저장
const participatedPhones = JSON.parse(
localStorage.getItem('participated_phones') || '[]'
);
participatedPhones.push(phone);
localStorage.setItem('participated_phones', JSON.stringify(participatedPhones));
try {
// API 요청 데이터 생성
const requestData: ParticipationRequest = {
name,
phoneNumber: phone,
email: email || undefined,
agreePrivacy: agreedToPrivacy,
channel: 'web', // 웹 참여
};
// API 호출
const response = await participate(eventId, requestData);
if (response.success) {
// 성공 시 다이얼로그 표시
setSuccessDialogOpen(true);
// 폼 초기화
setName('');
setPhone('');
setEmail('');
setAgreedToPrivacy(false);
} else {
// API 응답은 성공했지만 success가 false인 경우
setApiError(response.message || '참여 신청에 실패했습니다.');
}
} catch (error: any) {
console.error('참여 신청 오류:', error);
// 에러 메시지 처리
if (error.response?.data?.message) {
setApiError(error.response.data.message);
} else if (error.message) {
setApiError(error.message);
} else {
setApiError('참여 신청 중 오류가 발생했습니다. 다시 시도해주세요.');
}
} finally {
setSubmitting(false);
setSuccessDialogOpen(true);
}, 1000);
}
};
return (
@ -199,6 +217,13 @@ export default function EventParticipatePage() {
</Grid>
</Grid>
{/* API Error Alert */}
{apiError && (
<Alert severity="error" sx={{ mb: 3 }} icon={<ErrorIcon />} onClose={() => setApiError('')}>
{apiError}
</Alert>
)}
{/* Participation Form */}
<Card sx={{ borderRadius: 3 }}>
<CardContent sx={{ p: 3 }}>
@ -214,11 +239,13 @@ export default function EventParticipatePage() {
onChange={(e) => {
setName(e.target.value);
setNameError('');
setApiError('');
}}
error={!!nameError}
helperText={nameError}
helperText={nameError || '2자 이상 50자 이하로 입력해주세요'}
sx={{ mb: 2 }}
required
disabled={submitting}
/>
<TextField
@ -229,8 +256,23 @@ export default function EventParticipatePage() {
error={!!phoneError}
helperText={phoneError || '010-0000-0000 형식으로 입력해주세요'}
placeholder="010-0000-0000"
sx={{ mb: 3 }}
sx={{ mb: 2 }}
required
disabled={submitting}
/>
<TextField
fullWidth
label="이메일 (선택)"
type="email"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setApiError('');
}}
placeholder="example@email.com"
sx={{ mb: 3 }}
disabled={submitting}
/>
<Divider sx={{ mb: 2 }} />
@ -241,6 +283,7 @@ export default function EventParticipatePage() {
checked={agreedToPrivacy}
onChange={(e) => setAgreedToPrivacy(e.target.checked)}
color="primary"
disabled={submitting}
/>
}
label={
@ -279,19 +322,22 @@ export default function EventParticipatePage() {
fontSize: '1rem',
}}
>
{submitting ? '참여 중...' : '참여하기'}
{submitting ? (
<>
<CircularProgress size={24} sx={{ mr: 1, color: 'white' }} />
...
</>
) : (
'참여하기'
)}
</Button>
</form>
</CardContent>
</Card>
{/* Participants Count */}
{/* Participants Count Info */}
<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' }}>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
: {mockEventData.announcementDate}
</Typography>
</Box>
@ -307,7 +353,7 @@ export default function EventParticipatePage() {
<DialogContent dividers>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>1. </strong>
<br />- ,
<br />- , , ()
</Typography>
<Typography variant="body2" sx={{ mb: 2 }}>
<strong>2. </strong>

View File

@ -0,0 +1,92 @@
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig, AxiosResponse } from 'axios';
/**
* API URL
* 환경: Next.js CORS (/api/proxy -> localhost:8084)
* 프로덕션: 직접
*/
const API_BASE_URL = process.env.NODE_ENV === 'production'
? process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8084'
: '/api/proxy';
/**
* Axios
*/
const apiClient: AxiosInstance = axios.create({
baseURL: API_BASE_URL,
timeout: 30000, // 30초
headers: {
'Content-Type': 'application/json',
},
});
/**
*
* - (: 인증 )
*/
apiClient.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
// TODO: 필요 시 인증 토큰 추가
// const token = localStorage.getItem('accessToken');
// if (token && config.headers) {
// config.headers.Authorization = `Bearer ${token}`;
// }
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error: AxiosError) => {
console.error('[API Request Error]', error);
return Promise.reject(error);
}
);
/**
*
* - (: 에러 )
*/
apiClient.interceptors.response.use(
(response: AxiosResponse) => {
console.log(`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url}`, response.status);
return response;
},
(error: AxiosError) => {
// 에러 응답 처리
if (error.response) {
const { status, data } = error.response;
console.error(`[API Error] ${status}:`, data);
// 상태 코드별 처리
switch (status) {
case 400:
console.error('잘못된 요청입니다.');
break;
case 401:
console.error('인증이 필요합니다.');
// TODO: 로그인 페이지로 리다이렉트
break;
case 403:
console.error('접근 권한이 없습니다.');
break;
case 404:
console.error('요청한 리소스를 찾을 수 없습니다.');
break;
case 500:
console.error('서버 오류가 발생했습니다.');
break;
default:
console.error('알 수 없는 오류가 발생했습니다.');
}
} else if (error.request) {
// 요청은 전송되었으나 응답을 받지 못한 경우
console.error('[API Network Error]', error.message);
} else {
// 요청 설정 중 오류 발생
console.error('[API Setup Error]', error.message);
}
return Promise.reject(error);
}
);
export default apiClient;

View File

@ -0,0 +1,153 @@
import apiClient from './api-client';
import type {
ApiResponse,
PageResponse,
ParticipationRequest,
ParticipationResponse,
GetParticipantsParams,
} from '../types/api.types';
/**
* Participation API Service
* API
*/
/**
*
* POST /v1/events/{eventId}/participate
*/
export const participate = async (
eventId: string,
data: ParticipationRequest
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await apiClient.post<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participate`,
data
);
return response.data;
};
/**
* ()
* GET /v1/events/{eventId}/participants
*/
export const getParticipants = async (
params: GetParticipantsParams
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params;
const response = await apiClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/participants`,
{
params: {
storeVisited,
page,
size,
sort,
},
}
);
return response.data;
};
/**
*
* GET /v1/events/{eventId}/participants/{participantId}
*/
export const getParticipant = async (
eventId: string,
participantId: string
): Promise<ApiResponse<ParticipationResponse>> => {
const response = await apiClient.get<ApiResponse<ParticipationResponse>>(
`/v1/events/${eventId}/participants/${participantId}`
);
return response.data;
};
/**
* ( )
* API는 ,
*
*/
export const searchParticipants = async (
eventId: string,
searchTerm: string,
storeVisited?: boolean
): Promise<ParticipationResponse[]> => {
// 모든 페이지 데이터 가져오기
let allParticipants: ParticipationResponse[] = [];
let currentPage = 0;
let hasMore = true;
while (hasMore) {
const response = await getParticipants({
eventId,
storeVisited,
page: currentPage,
size: 100, // 한 번에 많이 가져오기
});
allParticipants = [...allParticipants, ...response.data.content];
hasMore = !response.data.last;
currentPage++;
}
// 검색어로 필터링
if (searchTerm) {
const term = searchTerm.toLowerCase();
return allParticipants.filter(
(p) =>
p.name.toLowerCase().includes(term) ||
p.phoneNumber.includes(term) ||
p.email?.toLowerCase().includes(term)
);
}
return allParticipants;
};
// ===================================
// Winner API Functions
// ===================================
/**
*
* POST /v1/events/{eventId}/draw-winners
*/
export const drawWinners = async (
eventId: string,
winnerCount: number,
applyStoreVisitBonus?: boolean
): Promise<ApiResponse<import('../types/api.types').DrawWinnersResponse>> => {
const response = await apiClient.post<ApiResponse<import('../types/api.types').DrawWinnersResponse>>(
`/v1/events/${eventId}/draw-winners`,
{
winnerCount,
applyStoreVisitBonus,
}
);
return response.data;
};
/**
*
* GET /v1/events/{eventId}/winners
*/
export const getWinners = async (
eventId: string,
page = 0,
size = 20,
sort: string[] = ['winnerRank,ASC']
): Promise<ApiResponse<PageResponse<ParticipationResponse>>> => {
const response = await apiClient.get<ApiResponse<PageResponse<ParticipationResponse>>>(
`/v1/events/${eventId}/winners`,
{
params: {
page,
size,
sort,
},
}
);
return response.data;
};

View File

@ -0,0 +1,151 @@
// ===================================
// Common API Response Types
// ===================================
/**
* API
*/
export interface ApiResponse<T = unknown> {
success: boolean;
data: T;
errorCode?: string;
message?: string;
timestamp: string;
}
/**
*
*/
export interface PageResponse<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
first: boolean;
last: boolean;
}
// ===================================
// Participation API Types
// ===================================
/**
*
*/
export interface ParticipationRequest {
/** 이름 (2-50자, 필수) */
name: string;
/** 전화번호 (패턴: ^\d{3}-\d{3,4}-\d{4}$, 필수) */
phoneNumber: string;
/** 이메일 (선택) */
email?: string;
/** 참여 경로 (선택) */
channel?: string;
/** 마케팅 동의 (선택) */
agreeMarketing?: boolean;
/** 개인정보 수집 동의 (필수) */
agreePrivacy: boolean;
/** 매장 방문 여부 (선택) */
storeVisited?: boolean;
}
/**
*
*/
export interface ParticipationResponse {
/** 참여자 ID */
participantId: string;
/** 이벤트 ID */
eventId: string;
/** 이름 */
name: string;
/** 전화번호 */
phoneNumber: string;
/** 이메일 */
email?: string;
/** 참여 경로 */
channel?: string;
/** 참여 일시 */
participatedAt: string;
/** 매장 방문 여부 */
storeVisited: boolean;
/** 보너스 응모권 수 */
bonusEntries: number;
/** 당첨 여부 */
isWinner: boolean;
}
/**
*
*/
export interface GetParticipantsParams {
/** 이벤트 ID */
eventId: string;
/** 매장 방문 여부 필터 (true: 방문자만, false: 미방문자만, null: 전체) */
storeVisited?: boolean;
/** 페이지 번호 (0부터 시작) */
page?: number;
/** 페이지 크기 */
size?: number;
/** 정렬 기준 (예: createdAt,DESC) */
sort?: string[];
}
// ===================================
// Winner API Types
// ===================================
/**
*
*/
export interface DrawWinnersRequest {
/** 당첨자 수 (최소 1) */
winnerCount: number;
/** 매장 방문 보너스 적용 여부 */
applyStoreVisitBonus?: boolean;
}
/**
*
*/
export interface WinnerSummary {
/** 참여자 ID */
participantId: string;
/** 이름 */
name: string;
/** 전화번호 */
phoneNumber: string;
/** 등수 */
rank: number;
}
/**
*
*/
export interface DrawWinnersResponse {
/** 이벤트 ID */
eventId: string;
/** 전체 참여자 수 */
totalParticipants: number;
/** 당첨자 수 */
winnerCount: number;
/** 추첨 일시 */
drawnAt: string;
/** 당첨자 목록 */
winners: WinnerSummary[];
}
/**
*
*/
export interface GetWinnersParams {
/** 이벤트 ID */
eventId: string;
/** 페이지 번호 (0부터 시작) */
page?: number;
/** 페이지 크기 */
size?: number;
/** 정렬 기준 (예: winnerRank,ASC) */
sort?: string[];
}