From 37ef11740c6f7feb5497999e5144a52e45aba5ab Mon Sep 17 00:00:00 2001 From: doyeon Date: Tue, 28 Oct 2025 15:06:44 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B6=94=EC=B2=A8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EC=8B=A4=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API 함수 추가: drawWinners, getWinners - 실제 백엔드 서버(localhost:8084)로 추첨 실행 - 당첨자 목록 실시간 조회 및 표시 - 에러 처리 및 로딩 상태 추가 - 재추첨 기능 API 연동 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 18 + next.config.js | 9 + package-lock.json | 8 +- package.json | 2 +- src/app/(main)/events/[eventId]/draw/page.tsx | 337 +++++----- .../events/[eventId]/participants/page.tsx | 629 ++++++++++-------- .../events/[eventId]/participate/page.tsx | 138 ++-- src/shared/api/api-client.ts | 92 +++ src/shared/api/participation.api.ts | 153 +++++ src/shared/types/api.types.ts | 151 +++++ 10 files changed, 1018 insertions(+), 519 deletions(-) create mode 100644 .claude/settings.local.json rename src/app/{ => (main)}/events/[eventId]/participate/page.tsx (76%) create mode 100644 src/shared/api/api-client.ts create mode 100644 src/shared/api/participation.api.ts create mode 100644 src/shared/types/api.types.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..d676d7f --- /dev/null +++ b/.claude/settings.local.json @@ -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": [] + } +} diff --git a/next.config.js b/next.config.js index 03b1343..7577db8 100644 --- a/next.config.js +++ b/next.config.js @@ -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 diff --git a/package-lock.json b/package-lock.json index 9644766..d8292b6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b4463e4..89d446c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/(main)/events/[eventId]/draw/page.tsx b/src/app/(main)/events/[eventId]/draw/page.tsx index e9d09fe..c393d42 100644 --- a/src/app/(main)/events/[eventId]/draw/page.tsx +++ b/src/app/(main)/events/[eventId]/draw/page.tsx @@ -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(null); + + // API 관련 상태 + const [totalParticipants, setTotalParticipants] = useState(0); + const [eventName] = useState('이벤트'); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [drawResult, setDrawResult] = useState(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 ( + + + + ); + } + return ( + {/* 에러 메시지 */} + {error && ( + setError(null)}> + {error} + + )} + {/* Setup View (Before Drawing) */} {!showResults && ( <> @@ -214,7 +287,7 @@ export default function DrawPage() { 이벤트명 - {mockEventData.name} + {eventName} @@ -234,7 +307,7 @@ export default function DrawPage() { 총 참여자 - {mockEventData.totalParticipants}명 + {totalParticipants}명 @@ -372,65 +445,6 @@ export default function DrawPage() { 추첨 시작 - {/* Drawing History */} - - - 📜 추첨 이력 - - {mockDrawingHistory.length === 0 ? ( - - - - - 추첨 이력이 없습니다 - - - - ) : ( - - {mockDrawingHistory.slice(0, 3).map((history, index) => ( - - - - - - {history.date} {history.isRedraw && '(재추첨)'} - - - 당첨자 {history.winnerCount}명 - - - - - - - ))} - - )} - )} @@ -443,8 +457,13 @@ export default function DrawPage() { 🎉 추첨 완료! - 총 {mockEventData.totalParticipants}명 중 {winnerCount}명 당첨 + 총 {totalParticipants}명 중 {winners.length}명 당첨 + {drawResult && ( + + 추첨 일시: {new Date(drawResult.drawnAt).toLocaleString('ko-KR')} + + )} {/* Winner List */} @@ -453,11 +472,10 @@ export default function DrawPage() { 🏆 당첨자 목록 - {winners.map((winner, index) => { - const rank = index + 1; + {winners.map((winner) => { return ( - {rank}위 + {winner.rank}위 - 응모번호: #{winner.id} + 참여자 ID: {winner.participantId} - {winner.name} ({winner.phone}) - - - 참여: {winner.channel}{' '} - {winner.hasBonus && storeBonus && '🌟'} + {winner.name} ({winner.phoneNumber}) + {winner.channel && ( + + 참여: {winner.channel}{' '} + {winner.storeVisited && storeBonus && '🌟'} + + )} @@ -671,8 +691,13 @@ export default function DrawPage() { - 총 {mockEventData.totalParticipants}명 중 {winnerCount}명을 추첨하시겠습니까? + 총 {totalParticipants}명 중 {winnerCount}명을 추첨하시겠습니까? + {storeBonus && ( + + 매장 방문 고객에게 1.5배 가산점이 적용됩니다. + + )} - - ); diff --git a/src/app/(main)/events/[eventId]/participants/page.tsx b/src/app/(main)/events/[eventId]/participants/page.tsx index c2236cf..c4fe727 100644 --- a/src/app/(main)/events/[eventId]/participants/page.tsx +++ b/src/app/(main)/events/[eventId]/participants/page.tsx @@ -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('all'); - const [statusFilter, setStatusFilter] = useState('all'); - const [currentPage, setCurrentPage] = useState(1); - const [selectedParticipant, setSelectedParticipant] = useState(null); - const [detailDialogOpen, setDetailDialogOpen] = useState(false); + // 데이터 상태 + const [participants, setParticipants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + // 필터 상태 + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [storeVisitedFilter, setStoreVisitedFilter] = useState(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(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, page: number) => { + setCurrentPage(page); + window.scrollTo({ top: 0, behavior: 'smooth' }); }; return ( @@ -198,9 +180,16 @@ export default function ParticipantsPage() { + {/* Error Alert */} + {error && ( + } onClose={() => setError('')}> + {error} + + )} + {/* Statistics Cards */} - + - {stats.total}명 + {loading ? '...' : stats.total}명 - + - {stats.waiting}명 + {loading ? '...' : stats.waiting}명 - + - {stats.winner}명 - - - - - - - - - - 당첨률 - - - {stats.total > 0 ? Math.round((stats.winner / stats.total) * 100) : 0}% + {loading ? '...' : stats.winner}명 @@ -286,7 +255,7 @@ export default function ParticipantsPage() { setSearchTerm(e.target.value)} InputProps={{ @@ -302,6 +271,7 @@ export default function ParticipantsPage() { bgcolor: 'white', }, }} + disabled={loading} /> @@ -310,17 +280,23 @@ export default function ParticipantsPage() { - 참여 경로 + 매장 방문 @@ -330,11 +306,11 @@ export default function ParticipantsPage() { label="상태" onChange={(e) => setStatusFilter(e.target.value as StatusType)} sx={{ borderRadius: 2 }} + disabled={loading} > 전체 당첨 대기 당첨 - 미당첨 @@ -344,13 +320,14 @@ export default function ParticipantsPage() { - 총 {filteredParticipants.length}명 참여 + 총 {filteredParticipants.length}명 표시 - {/* Participants Count */} + {/* Participants Count Info */} - - 현재 {mockEventData.participants}명이 - 참여했습니다 - - + 당첨자 발표: {mockEventData.announcementDate} @@ -307,7 +353,7 @@ export default function EventParticipatePage() { 1. 수집하는 개인정보 항목 -
- 이름, 전화번호 +
- 이름, 전화번호, 이메일(선택)
2. 개인정보의 수집 및 이용목적 diff --git a/src/shared/api/api-client.ts b/src/shared/api/api-client.ts new file mode 100644 index 0000000..270a5dc --- /dev/null +++ b/src/shared/api/api-client.ts @@ -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; diff --git a/src/shared/api/participation.api.ts b/src/shared/api/participation.api.ts new file mode 100644 index 0000000..b52082d --- /dev/null +++ b/src/shared/api/participation.api.ts @@ -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> => { + const response = await apiClient.post>( + `/v1/events/${eventId}/participate`, + data + ); + return response.data; +}; + +/** + * 참여자 목록 조회 (페이징) + * GET /v1/events/{eventId}/participants + */ +export const getParticipants = async ( + params: GetParticipantsParams +): Promise>> => { + const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params; + + const response = await apiClient.get>>( + `/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> => { + const response = await apiClient.get>( + `/v1/events/${eventId}/participants/${participantId}` + ); + return response.data; +}; + +/** + * 참여자 검색 (클라이언트 사이드 필터링용 헬퍼) + * 실제 API는 서버 사이드 검색을 지원하지 않으므로, + * 전체 목록을 가져와서 클라이언트에서 필터링 + */ +export const searchParticipants = async ( + eventId: string, + searchTerm: string, + storeVisited?: boolean +): Promise => { + // 모든 페이지 데이터 가져오기 + 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> => { + const response = await apiClient.post>( + `/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>> => { + const response = await apiClient.get>>( + `/v1/events/${eventId}/winners`, + { + params: { + page, + size, + sort, + }, + } + ); + return response.data; +}; diff --git a/src/shared/types/api.types.ts b/src/shared/types/api.types.ts new file mode 100644 index 0000000..b57f9ff --- /dev/null +++ b/src/shared/types/api.types.ts @@ -0,0 +1,151 @@ +// =================================== +// Common API Response Types +// =================================== + +/** + * 공통 API 응답 래퍼 + */ +export interface ApiResponse { + success: boolean; + data: T; + errorCode?: string; + message?: string; + timestamp: string; +} + +/** + * 페이징 응답 + */ +export interface PageResponse { + 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[]; +}