mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 20:46:25 +00:00
commit
bace9476b1
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,15 @@ const nextConfig = {
|
|||||||
env: {
|
env: {
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8080',
|
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
|
module.exports = nextConfig
|
||||||
|
|||||||
8
package-lock.json
generated
8
package-lock.json
generated
@ -17,7 +17,7 @@
|
|||||||
"@tanstack/react-query": "^5.59.16",
|
"@tanstack/react-query": "^5.59.16",
|
||||||
"@use-funnel/browser": "^0.0.12",
|
"@use-funnel/browser": "^0.0.12",
|
||||||
"@use-funnel/next": "^0.0.12",
|
"@use-funnel/next": "^0.0.12",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.13.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
@ -2122,9 +2122,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.12.2",
|
"version": "1.13.0",
|
||||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.0.tgz",
|
||||||
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
"integrity": "sha512-zt40Pz4zcRXra9CVV31KeyofwiNvAbJ5B6YPz9pMJ+yOSLikvPT4Yi5LjfgjRa9CawVYBaD1JQzIVcIvBejKeA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"follow-redirects": "^1.15.6",
|
"follow-redirects": "^1.15.6",
|
||||||
|
|||||||
@ -19,7 +19,7 @@
|
|||||||
"@tanstack/react-query": "^5.59.16",
|
"@tanstack/react-query": "^5.59.16",
|
||||||
"@use-funnel/browser": "^0.0.12",
|
"@use-funnel/browser": "^0.0.12",
|
||||||
"@use-funnel/next": "^0.0.12",
|
"@use-funnel/next": "^0.0.12",
|
||||||
"axios": "^1.7.7",
|
"axios": "^1.13.0",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"next": "^14.2.15",
|
"next": "^14.2.15",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -17,6 +17,8 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
IconButton,
|
IconButton,
|
||||||
Grid,
|
Grid,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
EventNote,
|
EventNote,
|
||||||
@ -25,12 +27,13 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Refresh,
|
Refresh,
|
||||||
Notifications,
|
Notifications,
|
||||||
List as ListIcon,
|
|
||||||
Add,
|
Add,
|
||||||
Remove,
|
Remove,
|
||||||
Info,
|
Info,
|
||||||
People,
|
People,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { drawWinners, getWinners, getParticipants } from '@/shared/api/participation.api';
|
||||||
|
import type { DrawWinnersResponse } from '@/shared/types/api.types';
|
||||||
|
|
||||||
// 디자인 시스템 색상
|
// 디자인 시스템 색상
|
||||||
const colors = {
|
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 {
|
interface Winner {
|
||||||
id: string;
|
participantId: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phoneNumber: string;
|
||||||
channel: string;
|
rank: number;
|
||||||
hasBonus: boolean;
|
channel?: string;
|
||||||
|
storeVisited: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DrawPage() {
|
export default function DrawPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
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
|
// State
|
||||||
const [winnerCount, setWinnerCount] = useState(5);
|
const [winnerCount, setWinnerCount] = useState(5);
|
||||||
@ -95,8 +78,60 @@ export default function DrawPage() {
|
|||||||
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
||||||
const [redrawDialogOpen, setRedrawDialogOpen] = useState(false);
|
const [redrawDialogOpen, setRedrawDialogOpen] = useState(false);
|
||||||
const [notifyDialogOpen, setNotifyDialogOpen] = 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 = () => {
|
const handleDecrease = () => {
|
||||||
if (winnerCount > 1) {
|
if (winnerCount > 1) {
|
||||||
@ -105,7 +140,7 @@ export default function DrawPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleIncrease = () => {
|
const handleIncrease = () => {
|
||||||
if (winnerCount < 100 && winnerCount < mockEventData.totalParticipants) {
|
if (winnerCount < 100 && winnerCount < totalParticipants) {
|
||||||
setWinnerCount(winnerCount + 1);
|
setWinnerCount(winnerCount + 1);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -114,34 +149,54 @@ export default function DrawPage() {
|
|||||||
setConfirmDialogOpen(true);
|
setConfirmDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const executeDrawing = () => {
|
const executeDrawing = async () => {
|
||||||
setConfirmDialogOpen(false);
|
setConfirmDialogOpen(false);
|
||||||
setIsDrawing(true);
|
setIsDrawing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
// Phase 1: 난수 생성 중 (1 second)
|
// Phase 1: 난수 생성 중 (1 second)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setAnimationText('당첨자 선정 중...');
|
setAnimationText('당첨자 선정 중...');
|
||||||
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
|
setAnimationSubtext('공정한 추첨을 진행하고 있습니다');
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
|
// 실제 API 호출
|
||||||
|
const response = await drawWinners(eventId, winnerCount, storeBonus);
|
||||||
|
setDrawResult(response.data);
|
||||||
|
|
||||||
// Phase 2: 완료 (2 seconds)
|
// Phase 2: 완료 (2 seconds)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setAnimationText('완료!');
|
setAnimationText('완료!');
|
||||||
setAnimationSubtext('추첨이 완료되었습니다');
|
setAnimationSubtext('추첨이 완료되었습니다');
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Phase 3: Show results (3 seconds)
|
// Phase 3: 당첨자 목록 변환 및 표시
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsDrawing(false);
|
const winnerList: Winner[] = response.data.winners.map((w) => ({
|
||||||
|
participantId: w.participantId,
|
||||||
|
name: w.name,
|
||||||
|
phoneNumber: w.phoneNumber,
|
||||||
|
rank: w.rank,
|
||||||
|
storeVisited: false, // API 응답에 포함되지 않음
|
||||||
|
}));
|
||||||
|
|
||||||
// Select random winners
|
setWinners(winnerList);
|
||||||
const shuffled = [...mockEventData.participants].sort(() => Math.random() - 0.5);
|
setIsDrawing(false);
|
||||||
setWinners(shuffled.slice(0, winnerCount));
|
|
||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
}, 3000);
|
}, 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 = () => {
|
const handleRedraw = async () => {
|
||||||
setRedrawDialogOpen(false);
|
setRedrawDialogOpen(false);
|
||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
setWinners([]);
|
setWinners([]);
|
||||||
@ -161,7 +216,7 @@ export default function DrawPage() {
|
|||||||
const handleDownload = () => {
|
const handleDownload = () => {
|
||||||
const now = new Date();
|
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 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} 다운로드를 시작합니다`);
|
alert(`${filename} 다운로드를 시작합니다`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -169,11 +224,6 @@ export default function DrawPage() {
|
|||||||
router.push('/events');
|
router.push('/events');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleHistoryDetail = (history: typeof mockDrawingHistory[0]) => {
|
|
||||||
setSelectedHistory(history);
|
|
||||||
setHistoryDetailOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRankClass = (rank: number) => {
|
const getRankClass = (rank: number) => {
|
||||||
if (rank === 1) return 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
|
if (rank === 1) return 'linear-gradient(135deg, #FFD700 0%, #FFA500 100%)';
|
||||||
if (rank === 2) return 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)';
|
if (rank === 2) return 'linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%)';
|
||||||
@ -181,9 +231,32 @@ export default function DrawPage() {
|
|||||||
return '#e0e0e0';
|
return '#e0e0e0';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 로딩 상태
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 10 }}>
|
||||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 8, md: 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) */}
|
{/* Setup View (Before Drawing) */}
|
||||||
{!showResults && (
|
{!showResults && (
|
||||||
<>
|
<>
|
||||||
@ -214,7 +287,7 @@ export default function DrawPage() {
|
|||||||
이벤트명
|
이벤트명
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, color: 'white' }}>
|
||||||
{mockEventData.name}
|
{eventName}
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -234,7 +307,7 @@ export default function DrawPage() {
|
|||||||
총 참여자
|
총 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
||||||
{mockEventData.totalParticipants}명
|
{totalParticipants}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -372,65 +445,6 @@ export default function DrawPage() {
|
|||||||
추첨 시작
|
추첨 시작
|
||||||
</Button>
|
</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>
|
||||||
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontSize: '1.25rem' }}>
|
||||||
총 {mockEventData.totalParticipants}명 중 {winnerCount}명 당첨
|
총 {totalParticipants}명 중 {winners.length}명 당첨
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{drawResult && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
|
추첨 일시: {new Date(drawResult.drawnAt).toLocaleString('ko-KR')}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Winner List */}
|
{/* Winner List */}
|
||||||
@ -453,11 +472,10 @@ export default function DrawPage() {
|
|||||||
🏆 당첨자 목록
|
🏆 당첨자 목록
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{winners.map((winner, index) => {
|
{winners.map((winner) => {
|
||||||
const rank = index + 1;
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={winner.id}
|
key={winner.participantId}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
@ -471,7 +489,7 @@ export default function DrawPage() {
|
|||||||
width: 64,
|
width: 64,
|
||||||
height: 64,
|
height: 64,
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: getRankClass(rank),
|
background: getRankClass(winner.rank),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -481,19 +499,21 @@ export default function DrawPage() {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{rank}위
|
{winner.rank}위
|
||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ flex: 1 }}>
|
<Box sx={{ flex: 1 }}>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||||
응모번호: #{winner.id}
|
참여자 ID: {winner.participantId}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 2, fontSize: '1.25rem' }}>
|
||||||
{winner.name} ({winner.phone})
|
{winner.name} ({winner.phoneNumber})
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{winner.channel && (
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||||
참여: {winner.channel}{' '}
|
참여: {winner.channel}{' '}
|
||||||
{winner.hasBonus && storeBonus && '🌟'}
|
{winner.storeVisited && storeBonus && '🌟'}
|
||||||
</Typography>
|
</Typography>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -671,8 +691,13 @@ export default function DrawPage() {
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogContent sx={{ px: 6, pb: 4 }}>
|
<DialogContent sx={{ px: 6, pb: 4 }}>
|
||||||
<Typography variant="body1" sx={{ textAlign: 'center', fontSize: '1.125rem' }}>
|
<Typography variant="body1" sx={{ textAlign: 'center', fontSize: '1.125rem' }}>
|
||||||
총 {mockEventData.totalParticipants}명 중 {winnerCount}명을 추첨하시겠습니까?
|
총 {totalParticipants}명 중 {winnerCount}명을 추첨하시겠습니까?
|
||||||
</Typography>
|
</Typography>
|
||||||
|
{storeBonus && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', mt: 2 }}>
|
||||||
|
매장 방문 고객에게 1.5배 가산점이 적용됩니다.
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
|
<DialogActions sx={{ px: 6, pb: 6, pt: 4, gap: 2 }}>
|
||||||
<Button
|
<Button
|
||||||
@ -825,56 +850,6 @@ export default function DrawPage() {
|
|||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Container>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@ -22,6 +22,8 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Grid,
|
Grid,
|
||||||
|
CircularProgress,
|
||||||
|
Alert,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@ -32,7 +34,10 @@ import {
|
|||||||
TrendingUp,
|
TrendingUp,
|
||||||
Person,
|
Person,
|
||||||
AccessTime,
|
AccessTime,
|
||||||
|
Error as ErrorIcon,
|
||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
|
import { getParticipants } from '@/shared/api/participation.api';
|
||||||
|
import type { ParticipationResponse } from '@/shared/types/api.types';
|
||||||
|
|
||||||
// 디자인 시스템 색상
|
// 디자인 시스템 색상
|
||||||
const colors = {
|
const colors = {
|
||||||
@ -52,115 +57,99 @@ const colors = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock 데이터
|
type StatusType = 'all' | 'winner' | 'waiting';
|
||||||
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() {
|
export default function ParticipantsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const eventId = params.eventId as string;
|
const eventId = params.eventId as string;
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
// 데이터 상태
|
||||||
const [channelFilter, setChannelFilter] = useState<ChannelType>('all');
|
const [participants, setParticipants] = useState<ParticipationResponse[]>([]);
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusType>('all');
|
const [loading, setLoading] = useState(true);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [error, setError] = useState('');
|
||||||
const [selectedParticipant, setSelectedParticipant] = useState<typeof mockParticipants[0] | null>(null);
|
|
||||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
|
// 필터 상태
|
||||||
|
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 itemsPerPage = 20;
|
||||||
|
|
||||||
// 필터링
|
// UI 상태
|
||||||
const filteredParticipants = mockParticipants.filter((participant) => {
|
const [selectedParticipant, setSelectedParticipant] = useState<ParticipationResponse | null>(null);
|
||||||
const matchesSearch =
|
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||||
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 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) {
|
||||||
const totalPages = Math.ceil(filteredParticipants.length / itemsPerPage);
|
setParticipants(response.data.content);
|
||||||
const startIndex = (currentPage - 1) * itemsPerPage;
|
setTotalPages(response.data.totalPages);
|
||||||
const endIndex = Math.min(startIndex + itemsPerPage, filteredParticipants.length);
|
setTotalElements(response.data.totalElements);
|
||||||
const pageParticipants = filteredParticipants.slice(startIndex, endIndex);
|
} else {
|
||||||
|
setError(response.message || '참여자 목록을 불러오는데 실패했습니다.');
|
||||||
const getStatusColor = (status: string) => {
|
}
|
||||||
switch (status) {
|
} catch (err: any) {
|
||||||
case 'waiting':
|
console.error('참여자 목록 로드 오류:', err);
|
||||||
return 'default';
|
setError(err.response?.data?.message || '참여자 목록을 불러오는데 실패했습니다.');
|
||||||
case 'winner':
|
} finally {
|
||||||
return 'success';
|
setLoading(false);
|
||||||
case 'loser':
|
|
||||||
return 'error';
|
|
||||||
default:
|
|
||||||
return 'default';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusText = (status: string) => {
|
// 초기 로드 및 필터 변경 시 재로드
|
||||||
switch (status) {
|
useEffect(() => {
|
||||||
case 'waiting':
|
loadParticipants();
|
||||||
return '당첨 대기';
|
}, [eventId, currentPage, storeVisitedFilter]);
|
||||||
case 'winner':
|
|
||||||
return '당첨';
|
// 클라이언트 사이드 필터링 (검색어, 당첨 여부)
|
||||||
case 'loser':
|
const filteredParticipants = participants.filter((participant) => {
|
||||||
return '미당첨';
|
const matchesSearch =
|
||||||
default:
|
searchTerm === '' ||
|
||||||
return status;
|
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 stats = {
|
||||||
|
total: totalElements,
|
||||||
|
waiting: participants.filter((p) => !p.isWinner).length,
|
||||||
|
winner: participants.filter((p) => p.isWinner).length,
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleParticipantClick = (participant: typeof mockParticipants[0]) => {
|
const getStatusText = (isWinner: boolean) => {
|
||||||
|
return isWinner ? '당첨' : '당첨 대기';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = (isWinner: boolean) => {
|
||||||
|
return isWinner ? 'success' : 'default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleParticipantClick = (participant: ParticipationResponse) => {
|
||||||
setSelectedParticipant(participant);
|
setSelectedParticipant(participant);
|
||||||
setDetailDialogOpen(true);
|
setDetailDialogOpen(true);
|
||||||
};
|
};
|
||||||
@ -173,16 +162,9 @@ export default function ParticipantsPage() {
|
|||||||
alert('엑셀 다운로드 기능은 추후 구현됩니다');
|
alert('엑셀 다운로드 기능은 추후 구현됩니다');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 통계 계산
|
const handlePageChange = (_: React.ChangeEvent<unknown>, page: number) => {
|
||||||
const stats = {
|
setCurrentPage(page);
|
||||||
total: mockParticipants.length,
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -198,9 +180,16 @@ export default function ParticipantsPage() {
|
|||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Error Alert */}
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 6 }} icon={<ErrorIcon />} onClose={() => setError('')}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Statistics Cards */}
|
{/* Statistics Cards */}
|
||||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
<Grid container spacing={6} sx={{ mb: 10 }}>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -215,12 +204,12 @@ export default function ParticipantsPage() {
|
|||||||
전체 참여자
|
전체 참여자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
||||||
{stats.total}명
|
{loading ? '...' : stats.total}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -235,12 +224,12 @@ export default function ParticipantsPage() {
|
|||||||
대기중
|
대기중
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
||||||
{stats.waiting}명
|
{loading ? '...' : stats.waiting}명
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid item xs={6} md={3}>
|
<Grid item xs={6} md={4}>
|
||||||
<Card
|
<Card
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
@ -255,27 +244,7 @@ export default function ParticipantsPage() {
|
|||||||
당첨자
|
당첨자
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white', fontSize: '1.75rem' }}>
|
||||||
{stats.winner}명
|
{loading ? '...' : 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}%
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@ -286,7 +255,7 @@ export default function ParticipantsPage() {
|
|||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
placeholder="이름 또는 전화번호 검색..."
|
placeholder="이름, 전화번호 또는 이메일 검색..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
@ -302,6 +271,7 @@ export default function ParticipantsPage() {
|
|||||||
bgcolor: 'white',
|
bgcolor: 'white',
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -310,17 +280,23 @@ export default function ParticipantsPage() {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, flexWrap: 'wrap' }}>
|
||||||
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
|
<FilterList sx={{ fontSize: 28, color: colors.pink }} />
|
||||||
<FormControl sx={{ flex: 1, minWidth: 160 }}>
|
<FormControl sx={{ flex: 1, minWidth: 160 }}>
|
||||||
<InputLabel>참여 경로</InputLabel>
|
<InputLabel>매장 방문</InputLabel>
|
||||||
<Select
|
<Select
|
||||||
value={channelFilter}
|
value={storeVisitedFilter === undefined ? 'all' : storeVisitedFilter ? 'visited' : 'not_visited'}
|
||||||
label="참여 경로"
|
label="매장 방문"
|
||||||
onChange={(e) => setChannelFilter(e.target.value as ChannelType)}
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setStoreVisitedFilter(
|
||||||
|
value === 'all' ? undefined : value === 'visited' ? true : false
|
||||||
|
);
|
||||||
|
setCurrentPage(1); // 필터 변경 시 첫 페이지로
|
||||||
|
}}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{ borderRadius: 2 }}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체 경로</MenuItem>
|
<MenuItem value="all">전체</MenuItem>
|
||||||
<MenuItem value="uriTV">우리동네TV</MenuItem>
|
<MenuItem value="visited">방문</MenuItem>
|
||||||
<MenuItem value="ringoBiz">링고비즈</MenuItem>
|
<MenuItem value="not_visited">미방문</MenuItem>
|
||||||
<MenuItem value="sns">SNS</MenuItem>
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormControl sx={{ flex: 1, minWidth: 140 }}>
|
<FormControl sx={{ flex: 1, minWidth: 140 }}>
|
||||||
@ -330,11 +306,11 @@ export default function ParticipantsPage() {
|
|||||||
label="상태"
|
label="상태"
|
||||||
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
onChange={(e) => setStatusFilter(e.target.value as StatusType)}
|
||||||
sx={{ borderRadius: 2 }}
|
sx={{ borderRadius: 2 }}
|
||||||
|
disabled={loading}
|
||||||
>
|
>
|
||||||
<MenuItem value="all">전체</MenuItem>
|
<MenuItem value="all">전체</MenuItem>
|
||||||
<MenuItem value="waiting">당첨 대기</MenuItem>
|
<MenuItem value="waiting">당첨 대기</MenuItem>
|
||||||
<MenuItem value="winner">당첨</MenuItem>
|
<MenuItem value="winner">당첨</MenuItem>
|
||||||
<MenuItem value="loser">미당첨</MenuItem>
|
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</Box>
|
</Box>
|
||||||
@ -344,13 +320,14 @@ export default function ParticipantsPage() {
|
|||||||
<Box sx={{ mb: 6 }}>
|
<Box sx={{ mb: 6 }}>
|
||||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: 4 }}>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
<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>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 3 }}>
|
<Box sx={{ display: 'flex', gap: 3 }}>
|
||||||
<Button
|
<Button
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
startIcon={<Download />}
|
startIcon={<Download />}
|
||||||
onClick={handleDownloadClick}
|
onClick={handleDownloadClick}
|
||||||
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: 4,
|
||||||
@ -369,6 +346,7 @@ export default function ParticipantsPage() {
|
|||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<Casino />}
|
startIcon={<Casino />}
|
||||||
onClick={handleDrawClick}
|
onClick={handleDrawClick}
|
||||||
|
disabled={loading}
|
||||||
sx={{
|
sx={{
|
||||||
borderRadius: 3,
|
borderRadius: 3,
|
||||||
px: 4,
|
px: 4,
|
||||||
@ -386,23 +364,38 @@ export default function ParticipantsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Participant List */}
|
{/* Loading State */}
|
||||||
<Box sx={{ mb: 10 }}>
|
{loading && (
|
||||||
{pageParticipants.length === 0 ? (
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 16 }}>
|
||||||
|
<CircularProgress size={60} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty State */}
|
||||||
|
{!loading && filteredParticipants.length === 0 && (
|
||||||
<Box sx={{ textAlign: 'center', py: 16 }}>
|
<Box sx={{ textAlign: 'center', py: 16 }}>
|
||||||
<Person sx={{ fontSize: 80, color: colors.gray[300], mb: 3 }} />
|
<Person sx={{ fontSize: 80, color: colors.gray[300], mb: 3 }} />
|
||||||
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
|
<Typography variant="h6" color="text.secondary" sx={{ fontWeight: 600 }}>
|
||||||
검색 결과가 없습니다
|
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
|
||||||
|
? '검색 결과가 없습니다'
|
||||||
|
: '아직 참여자가 없습니다'}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
|
||||||
다른 검색어나 필터를 사용해보세요
|
{searchTerm || statusFilter !== 'all' || storeVisitedFilter !== undefined
|
||||||
|
? '다른 검색어나 필터를 사용해보세요'
|
||||||
|
: '첫 번째 참여자를 기다리고 있습니다'}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
)}
|
||||||
|
|
||||||
|
{/* Participant List */}
|
||||||
|
{!loading && filteredParticipants.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Box sx={{ mb: 10 }}>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||||
{pageParticipants.map((participant) => (
|
{filteredParticipants.map((participant) => (
|
||||||
<Card
|
<Card
|
||||||
key={participant.id}
|
key={participant.participantId}
|
||||||
elevation={0}
|
elevation={0}
|
||||||
sx={{
|
sx={{
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
@ -442,19 +435,19 @@ export default function ParticipantsPage() {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block' }}>
|
||||||
#{participant.id}
|
#{participant.participantId}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
<Typography variant="h6" sx={{ fontWeight: 700, mb: 1, fontSize: '1.25rem' }}>
|
||||||
{participant.name}
|
{participant.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary">
|
||||||
{participant.phone}
|
{participant.phoneNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Chip
|
<Chip
|
||||||
label={getStatusText(participant.status)}
|
label={getStatusText(participant.isWinner)}
|
||||||
color={getStatusColor(participant.status) as any}
|
color={getStatusColor(participant.isWinner) as any}
|
||||||
size="medium"
|
size="medium"
|
||||||
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
|
sx={{ fontWeight: 600, px: 2, py: 2.5 }}
|
||||||
/>
|
/>
|
||||||
@ -471,6 +464,7 @@ export default function ParticipantsPage() {
|
|||||||
gap: 2,
|
gap: 2,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{participant.channel && (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary">
|
||||||
참여 경로
|
참여 경로
|
||||||
@ -485,20 +479,28 @@ export default function ParticipantsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Typography variant="body1" color="text.secondary">
|
<Typography variant="body1" color="text.secondary">
|
||||||
참여 일시
|
참여 일시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||||
{participant.date}
|
{new Date(participant.participatedAt).toLocaleString('ko-KR')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Pagination */}
|
{/* Pagination */}
|
||||||
@ -507,7 +509,7 @@ export default function ParticipantsPage() {
|
|||||||
<Pagination
|
<Pagination
|
||||||
count={totalPages}
|
count={totalPages}
|
||||||
page={currentPage}
|
page={currentPage}
|
||||||
onChange={(_, page) => setCurrentPage(page)}
|
onChange={handlePageChange}
|
||||||
color="primary"
|
color="primary"
|
||||||
size="large"
|
size="large"
|
||||||
sx={{
|
sx={{
|
||||||
@ -519,6 +521,8 @@ export default function ParticipantsPage() {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Participant Detail Dialog */}
|
{/* Participant Detail Dialog */}
|
||||||
<Dialog
|
<Dialog
|
||||||
@ -560,7 +564,7 @@ export default function ParticipantsPage() {
|
|||||||
{selectedParticipant.name}
|
{selectedParticipant.name}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="body2" color="text.secondary">
|
||||||
#{selectedParticipant.id}
|
#{selectedParticipant.participantId}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -576,10 +580,28 @@ export default function ParticipantsPage() {
|
|||||||
전화번호
|
전화번호
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||||
{selectedParticipant.phone}
|
{selectedParticipant.phoneNumber}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
p: 3,
|
p: 3,
|
||||||
@ -594,6 +616,7 @@ export default function ParticipantsPage() {
|
|||||||
{selectedParticipant.channel}
|
{selectedParticipant.channel}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
<Box
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
@ -606,7 +629,7 @@ export default function ParticipantsPage() {
|
|||||||
참여 일시
|
참여 일시
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
<Typography variant="body1" sx={{ fontWeight: 600 }}>
|
||||||
{selectedParticipant.date}
|
{new Date(selectedParticipant.participatedAt).toLocaleString('ko-KR')}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
@ -621,12 +644,44 @@ export default function ParticipantsPage() {
|
|||||||
당첨 여부
|
당첨 여부
|
||||||
</Typography>
|
</Typography>
|
||||||
<Chip
|
<Chip
|
||||||
label={getStatusText(selectedParticipant.status)}
|
label={getStatusText(selectedParticipant.isWinner)}
|
||||||
color={getStatusColor(selectedParticipant.status) as any}
|
color={getStatusColor(selectedParticipant.isWinner) as any}
|
||||||
size="medium"
|
size="medium"
|
||||||
sx={{ fontWeight: 600 }}
|
sx={{ fontWeight: 600 }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</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>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Container,
|
Container,
|
||||||
@ -17,34 +18,36 @@ import {
|
|||||||
DialogActions,
|
DialogActions,
|
||||||
Grid,
|
Grid,
|
||||||
Divider,
|
Divider,
|
||||||
|
Alert,
|
||||||
|
CircularProgress,
|
||||||
} from '@mui/material';
|
} 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 = {
|
const mockEventData = {
|
||||||
id: '1',
|
|
||||||
title: '신규고객 유치 이벤트',
|
title: '신규고객 유치 이벤트',
|
||||||
prize: '커피 쿠폰',
|
prize: '커피 쿠폰',
|
||||||
startDate: '2025-11-01',
|
startDate: '2025-11-01',
|
||||||
endDate: '2025-11-15',
|
endDate: '2025-11-15',
|
||||||
announcementDate: '2025-11-20',
|
announcementDate: '2025-11-20',
|
||||||
participants: 128,
|
|
||||||
image: '/images/event-banner.jpg',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EventParticipatePage() {
|
export default function EventParticipatePage() {
|
||||||
// const params = useParams();
|
const params = useParams();
|
||||||
// eventId will be used for API calls in future
|
const eventId = params.eventId as string;
|
||||||
// const eventId = params.eventId as string;
|
|
||||||
|
|
||||||
// Form state
|
// Form state
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
const [agreedToPrivacy, setAgreedToPrivacy] = useState(false);
|
const [agreedToPrivacy, setAgreedToPrivacy] = useState(false);
|
||||||
|
|
||||||
// Error state
|
// Error state
|
||||||
const [nameError, setNameError] = useState('');
|
const [nameError, setNameError] = useState('');
|
||||||
const [phoneError, setPhoneError] = useState('');
|
const [phoneError, setPhoneError] = useState('');
|
||||||
|
const [apiError, setApiError] = useState('');
|
||||||
|
|
||||||
// UI state
|
// UI state
|
||||||
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
|
const [privacyDialogOpen, setPrivacyDialogOpen] = useState(false);
|
||||||
@ -63,21 +66,22 @@ export default function EventParticipatePage() {
|
|||||||
const formatted = formatPhoneNumber(e.target.value);
|
const formatted = formatPhoneNumber(e.target.value);
|
||||||
setPhone(formatted);
|
setPhone(formatted);
|
||||||
setPhoneError('');
|
setPhoneError('');
|
||||||
|
setApiError('');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 유효성 검사
|
// 유효성 검사
|
||||||
const validateForm = () => {
|
const validateForm = () => {
|
||||||
let isValid = true;
|
let isValid = true;
|
||||||
|
|
||||||
// 이름 검증
|
// 이름 검증 (2-50자)
|
||||||
if (name.length < 2) {
|
if (name.length < 2 || name.length > 50) {
|
||||||
setNameError('이름은 2자 이상이어야 합니다');
|
setNameError('이름은 2자 이상 50자 이하이어야 합니다');
|
||||||
isValid = false;
|
isValid = false;
|
||||||
} else {
|
} else {
|
||||||
setNameError('');
|
setNameError('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 전화번호 검증
|
// 전화번호 검증 (패턴: ^\d{3}-\d{3,4}-\d{4}$)
|
||||||
const phonePattern = /^010-\d{4}-\d{4}$/;
|
const phonePattern = /^010-\d{4}-\d{4}$/;
|
||||||
if (!phonePattern.test(phone)) {
|
if (!phonePattern.test(phone)) {
|
||||||
setPhoneError('올바른 전화번호 형식이 아닙니다 (010-0000-0000)');
|
setPhoneError('올바른 전화번호 형식이 아닙니다 (010-0000-0000)');
|
||||||
@ -95,14 +99,6 @@ export default function EventParticipatePage() {
|
|||||||
return isValid;
|
return isValid;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 중복 참여 체크
|
|
||||||
const checkDuplicate = () => {
|
|
||||||
const participatedPhones = JSON.parse(
|
|
||||||
localStorage.getItem('participated_phones') || '[]'
|
|
||||||
);
|
|
||||||
return participatedPhones.includes(phone);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 제출 처리
|
// 제출 처리
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -111,26 +107,48 @@ export default function EventParticipatePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 중복 참여 체크
|
|
||||||
if (checkDuplicate()) {
|
|
||||||
alert('이미 참여하신 전화번호입니다');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
setApiError('');
|
||||||
|
|
||||||
// API 호출 시뮬레이션
|
try {
|
||||||
setTimeout(() => {
|
// API 요청 데이터 생성
|
||||||
// 참여 기록 저장
|
const requestData: ParticipationRequest = {
|
||||||
const participatedPhones = JSON.parse(
|
name,
|
||||||
localStorage.getItem('participated_phones') || '[]'
|
phoneNumber: phone,
|
||||||
);
|
email: email || undefined,
|
||||||
participatedPhones.push(phone);
|
agreePrivacy: agreedToPrivacy,
|
||||||
localStorage.setItem('participated_phones', JSON.stringify(participatedPhones));
|
channel: 'web', // 웹 참여
|
||||||
|
};
|
||||||
|
|
||||||
setSubmitting(false);
|
// API 호출
|
||||||
|
const response = await participate(eventId, requestData);
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 성공 시 다이얼로그 표시
|
||||||
setSuccessDialogOpen(true);
|
setSuccessDialogOpen(true);
|
||||||
}, 1000);
|
// 폼 초기화
|
||||||
|
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);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -199,6 +217,13 @@ export default function EventParticipatePage() {
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
{/* API Error Alert */}
|
||||||
|
{apiError && (
|
||||||
|
<Alert severity="error" sx={{ mb: 3 }} icon={<ErrorIcon />} onClose={() => setApiError('')}>
|
||||||
|
{apiError}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Participation Form */}
|
{/* Participation Form */}
|
||||||
<Card sx={{ borderRadius: 3 }}>
|
<Card sx={{ borderRadius: 3 }}>
|
||||||
<CardContent sx={{ p: 3 }}>
|
<CardContent sx={{ p: 3 }}>
|
||||||
@ -214,11 +239,13 @@ export default function EventParticipatePage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setName(e.target.value);
|
setName(e.target.value);
|
||||||
setNameError('');
|
setNameError('');
|
||||||
|
setApiError('');
|
||||||
}}
|
}}
|
||||||
error={!!nameError}
|
error={!!nameError}
|
||||||
helperText={nameError}
|
helperText={nameError || '2자 이상 50자 이하로 입력해주세요'}
|
||||||
sx={{ mb: 2 }}
|
sx={{ mb: 2 }}
|
||||||
required
|
required
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TextField
|
<TextField
|
||||||
@ -229,8 +256,23 @@ export default function EventParticipatePage() {
|
|||||||
error={!!phoneError}
|
error={!!phoneError}
|
||||||
helperText={phoneError || '010-0000-0000 형식으로 입력해주세요'}
|
helperText={phoneError || '010-0000-0000 형식으로 입력해주세요'}
|
||||||
placeholder="010-0000-0000"
|
placeholder="010-0000-0000"
|
||||||
sx={{ mb: 3 }}
|
sx={{ mb: 2 }}
|
||||||
required
|
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 }} />
|
<Divider sx={{ mb: 2 }} />
|
||||||
@ -241,6 +283,7 @@ export default function EventParticipatePage() {
|
|||||||
checked={agreedToPrivacy}
|
checked={agreedToPrivacy}
|
||||||
onChange={(e) => setAgreedToPrivacy(e.target.checked)}
|
onChange={(e) => setAgreedToPrivacy(e.target.checked)}
|
||||||
color="primary"
|
color="primary"
|
||||||
|
disabled={submitting}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
label={
|
label={
|
||||||
@ -279,19 +322,22 @@ export default function EventParticipatePage() {
|
|||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{submitting ? '참여 중...' : '참여하기'}
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<CircularProgress size={24} sx={{ mr: 1, color: 'white' }} />
|
||||||
|
참여 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'참여하기'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Participants Count */}
|
{/* Participants Count Info */}
|
||||||
<Box sx={{ textAlign: 'center', mt: 3 }}>
|
<Box sx={{ textAlign: 'center', mt: 3 }}>
|
||||||
<Typography variant="body2" color="text.secondary">
|
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
|
||||||
현재 <strong style={{ color: '#e91e63' }}>{mockEventData.participants}명</strong>이
|
|
||||||
참여했습니다
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
|
|
||||||
당첨자 발표: {mockEventData.announcementDate}
|
당첨자 발표: {mockEventData.announcementDate}
|
||||||
</Typography>
|
</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
@ -307,7 +353,7 @@ export default function EventParticipatePage() {
|
|||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
<strong>1. 수집하는 개인정보 항목</strong>
|
<strong>1. 수집하는 개인정보 항목</strong>
|
||||||
<br />- 이름, 전화번호
|
<br />- 이름, 전화번호, 이메일(선택)
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="body2" sx={{ mb: 2 }}>
|
<Typography variant="body2" sx={{ mb: 2 }}>
|
||||||
<strong>2. 개인정보의 수집 및 이용목적</strong>
|
<strong>2. 개인정보의 수집 및 이용목적</strong>
|
||||||
92
src/shared/api/api-client.ts
Normal file
92
src/shared/api/api-client.ts
Normal 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;
|
||||||
153
src/shared/api/participation.api.ts
Normal file
153
src/shared/api/participation.api.ts
Normal 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;
|
||||||
|
};
|
||||||
151
src/shared/types/api.types.ts
Normal file
151
src/shared/types/api.types.ts
Normal 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[];
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user