From 37ef11740c6f7feb5497999e5144a52e45aba5ab Mon Sep 17 00:00:00 2001 From: doyeon Date: Tue, 28 Oct 2025 15:06:44 +0900 Subject: [PATCH 1/4] =?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[]; +} From 37e5a76c508561eaed25759f405146255bc1c224 Mon Sep 17 00:00:00 2001 From: jhbkjh Date: Tue, 28 Oct 2025 17:49:20 +0900 Subject: [PATCH 2/4] =?UTF-8?q?Participation=20API=EB=A5=BC=20client.ts?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=ED=95=A9=20=EB=B0=8F=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=20=EC=84=A4=EC=A0=95=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-client.ts 삭제하고 client.ts의 participationClient 사용 - 마이크로서비스별 호스트 환경변수 지원 추가 - API_VERSION 환경변수로 api prefix 관리 - .env.local 파일 생성 (개발 환경 설정) - CORS 해결을 위해 백엔드에서 직접 호출하는 방식으로 단순화 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 3 +- .env.example | 4 +- src/shared/api/api-client.ts | 92 -------------------- src/shared/api/client.ts | 126 +++++++++++++++++----------- src/shared/api/participation.api.ts | 12 +-- 5 files changed, 87 insertions(+), 150 deletions(-) delete mode 100644 src/shared/api/api-client.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index d676d7f..453fabd 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,8 @@ "Bash(netstat:*)", "Bash(taskkill:*)", "Bash(ls:*)", - "Bash(git add:*)" + "Bash(git add:*)", + "Bash(git commit:*)" ], "deny": [], "ask": [] diff --git a/.env.example b/.env.example index 9455646..1cf6290 100644 --- a/.env.example +++ b/.env.example @@ -7,5 +7,5 @@ NEXT_PUBLIC_PARTICIPATION_HOST=http://localhost:8084 NEXT_PUBLIC_DISTRIBUTION_HOST=http://localhost:8085 NEXT_PUBLIC_ANALYTICS_HOST=http://localhost:8086 -# API Version -NEXT_PUBLIC_API_VERSION=v1 +# API Version prefix +NEXT_PUBLIC_API_VERSION=api diff --git a/src/shared/api/api-client.ts b/src/shared/api/api-client.ts deleted file mode 100644 index 270a5dc..0000000 --- a/src/shared/api/api-client.ts +++ /dev/null @@ -1,92 +0,0 @@ -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/client.ts b/src/shared/api/client.ts index 16aefe9..5709573 100644 --- a/src/shared/api/client.ts +++ b/src/shared/api/client.ts @@ -1,67 +1,95 @@ import axios, { AxiosInstance, AxiosError, InternalAxiosRequestConfig } from 'axios'; -const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://20.196.65.160:8081'; +// 마이크로서비스별 호스트 설정 +const API_HOSTS = { + user: process.env.NEXT_PUBLIC_USER_HOST || 'http://localhost:8081', + event: process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080', + content: process.env.NEXT_PUBLIC_CONTENT_HOST || 'http://localhost:8082', + ai: process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083', + participation: process.env.NEXT_PUBLIC_PARTICIPATION_HOST || 'http://localhost:8084', + distribution: process.env.NEXT_PUBLIC_DISTRIBUTION_HOST || 'http://localhost:8085', + analytics: process.env.NEXT_PUBLIC_ANALYTICS_HOST || 'http://localhost:8086', +}; + +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api'; + +// 기본 User API 클라이언트 (기존 호환성 유지) +const API_BASE_URL = API_HOSTS.user; export const apiClient: AxiosInstance = axios.create({ baseURL: API_BASE_URL, - timeout: 90000, // 30초로 증가 + timeout: 90000, headers: { 'Content-Type': 'application/json', }, }); -// Request interceptor - JWT 토큰 추가 -apiClient.interceptors.request.use( - (config: InternalAxiosRequestConfig) => { - console.log('🚀 API Request:', { - method: config.method?.toUpperCase(), - url: config.url, - baseURL: config.baseURL, - data: config.data, - }); - - const token = localStorage.getItem('accessToken'); - if (token && config.headers) { - config.headers.Authorization = `Bearer ${token}`; - console.log('🔑 Token added to request'); - } - return config; +// Participation API 전용 클라이언트 +export const participationClient: AxiosInstance = axios.create({ + baseURL: `${API_HOSTS.participation}/${API_VERSION}`, + timeout: 90000, + headers: { + 'Content-Type': 'application/json', }, - (error: AxiosError) => { - console.error('❌ Request Error:', error); - return Promise.reject(error); +}); + +// 공통 Request interceptor 함수 +const requestInterceptor = (config: InternalAxiosRequestConfig) => { + console.log('🚀 API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + console.log('🔑 Token added to request'); } -); + return config; +}; -// Response interceptor - 에러 처리 -apiClient.interceptors.response.use( - (response) => { - console.log('✅ API Response:', { - status: response.status, - url: response.config.url, - data: response.data, - }); - return response; - }, - (error: AxiosError) => { - console.error('❌ API Error:', { - message: error.message, - status: error.response?.status, - statusText: error.response?.statusText, - url: error.config?.url, - data: error.response?.data, - }); +const requestErrorInterceptor = (error: AxiosError) => { + console.error('❌ Request Error:', error); + return Promise.reject(error); +}; - if (error.response?.status === 401) { - console.warn('🔒 401 Unauthorized - Redirecting to login'); - // 인증 실패 시 토큰 삭제 및 로그인 페이지로 리다이렉트 - localStorage.removeItem('accessToken'); - if (typeof window !== 'undefined') { - window.location.href = '/login'; - } +// 공통 Response interceptor 함수 +const responseInterceptor = (response: any) => { + console.log('✅ API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; +}; + +const responseErrorInterceptor = (error: AxiosError) => { + console.error('❌ API Error:', { + message: error.message, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + data: error.response?.data, + }); + + if (error.response?.status === 401) { + console.warn('🔒 401 Unauthorized - Redirecting to login'); + localStorage.removeItem('accessToken'); + if (typeof window !== 'undefined') { + window.location.href = '/login'; } - return Promise.reject(error); } -); + return Promise.reject(error); +}; + +// User API Client 인터셉터 적용 +apiClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor); +apiClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor); + +// Participation API Client 인터셉터 적용 +participationClient.interceptors.request.use(requestInterceptor, requestErrorInterceptor); +participationClient.interceptors.response.use(responseInterceptor, responseErrorInterceptor); export default apiClient; diff --git a/src/shared/api/participation.api.ts b/src/shared/api/participation.api.ts index b52082d..3eeb37b 100644 --- a/src/shared/api/participation.api.ts +++ b/src/shared/api/participation.api.ts @@ -1,4 +1,4 @@ -import apiClient from './api-client'; +import { participationClient } from './client'; import type { ApiResponse, PageResponse, @@ -20,7 +20,7 @@ export const participate = async ( eventId: string, data: ParticipationRequest ): Promise> => { - const response = await apiClient.post>( + const response = await participationClient.post>( `/v1/events/${eventId}/participate`, data ); @@ -36,7 +36,7 @@ export const getParticipants = async ( ): Promise>> => { const { eventId, storeVisited, page = 0, size = 20, sort = ['createdAt,DESC'] } = params; - const response = await apiClient.get>>( + const response = await participationClient.get>>( `/v1/events/${eventId}/participants`, { params: { @@ -58,7 +58,7 @@ export const getParticipant = async ( eventId: string, participantId: string ): Promise> => { - const response = await apiClient.get>( + const response = await participationClient.get>( `/v1/events/${eventId}/participants/${participantId}` ); return response.data; @@ -119,7 +119,7 @@ export const drawWinners = async ( winnerCount: number, applyStoreVisitBonus?: boolean ): Promise> => { - const response = await apiClient.post>( + const response = await participationClient.post>( `/v1/events/${eventId}/draw-winners`, { winnerCount, @@ -139,7 +139,7 @@ export const getWinners = async ( size = 20, sort: string[] = ['winnerRank,ASC'] ): Promise>> => { - const response = await apiClient.get>>( + const response = await participationClient.get>>( `/v1/events/${eventId}/winners`, { params: { From 6cccafa822d13e787765576a6870021a96e342e8 Mon Sep 17 00:00:00 2001 From: cherry2250 Date: Tue, 28 Oct 2025 23:08:57 +0900 Subject: [PATCH 3/4] =?UTF-8?q?AI=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EA=B8=B0=EB=8A=A5=20=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=8B=A4=EC=A0=9C=20API=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 변경사항: - Step flow 통합: localStorage 기반 eventId 사용 - 자동 이미지 생성: 이미지 없을 시 자동 생성 트리거 - 진행률 바 추가: 0-100% 진행률 표시 - 동적 로딩 메시지: 단계별 메시지 업데이트 - Next.js 15 API routes 수정: params를 Promise로 처리 - 실제 배포 API 연동: Content API 서버 URL 설정 기술 세부사항: - API proxy routes 추가 (CORS 우회) - 2초 폴링 메커니즘 (최대 60초) - 환경변수: NEXT_PUBLIC_CONTENT_API_URL 설정 - CDN URL 디버그 오버레이 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- API_CHANGES.md | 263 +++++++++ CORS_FIX.md | 221 ++++++++ FIX_EVENTID_MISMATCH.md | 182 +++++++ MOCK_DATA_SETUP.md | 149 ++++++ QUICK_TEST.md | 145 +++++ TEST_URLS.md | 117 ++++ next.config.js | 2 +- public/init-mock-data.html | 205 +++++++ setup-mock-data.js | 17 + src/app/(main)/events/create/page.tsx | 23 +- .../create/steps/ContentPreviewStep.tsx | 505 ++++++++++++++---- .../events/[eventDraftId]/images/route.ts | 52 ++ src/app/api/content/images/generate/route.ts | 42 ++ .../api/content/images/jobs/[jobId]/route.ts | 42 ++ src/shared/api/contentApi.ts | 160 ++++++ test-images.html | 124 +++++ test-localstorage.html | 182 +++++++ 17 files changed, 2316 insertions(+), 115 deletions(-) create mode 100644 API_CHANGES.md create mode 100644 CORS_FIX.md create mode 100644 FIX_EVENTID_MISMATCH.md create mode 100644 MOCK_DATA_SETUP.md create mode 100644 QUICK_TEST.md create mode 100644 TEST_URLS.md create mode 100644 public/init-mock-data.html create mode 100644 setup-mock-data.js create mode 100644 src/app/api/content/events/[eventDraftId]/images/route.ts create mode 100644 src/app/api/content/images/generate/route.ts create mode 100644 src/app/api/content/images/jobs/[jobId]/route.ts create mode 100644 src/shared/api/contentApi.ts create mode 100644 test-images.html create mode 100644 test-localstorage.html diff --git a/API_CHANGES.md b/API_CHANGES.md new file mode 100644 index 0000000..f54789e --- /dev/null +++ b/API_CHANGES.md @@ -0,0 +1,263 @@ +# Content API 변경사항 + +## 📋 주요 변경사항 요약 + +### 1. **eventDraftId → eventId 타입 변경** + +| 항목 | 기존 (Old) | 변경 (New) | +|------|-----------|-----------| +| 필드명 | `eventDraftId` | `eventId` | +| 타입 | `number` | `string` | +| 예시 | `7777` | `"7777"` | + +--- + +## 🔄 영향을 받는 인터페이스 + +### GenerateImagesRequest + +**Before:** +```typescript +interface GenerateImagesRequest { + eventDraftId: number; + eventTitle: string; + eventDescription: string; + industry?: string; + location?: string; + trends?: string[]; + styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[]; + platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[]; +} +``` + +**After:** +```typescript +interface GenerateImagesRequest { + eventId: string; // Changed from eventDraftId: number + eventTitle: string; + eventDescription: string; + industry?: string; + location?: string; + trends?: string[]; + styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[]; + platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[]; +} +``` + +### JobInfo + +**Before:** +```typescript +interface JobInfo { + id: string; + eventDraftId: number; + jobType: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultMessage?: string; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface JobInfo { + id: string; + eventId: string; // Changed from eventDraftId: number + jobType: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultMessage?: string; + errorMessage?: string; + createdAt: string; + updatedAt: string; +} +``` + +### ImageInfo + +**Before:** +```typescript +interface ImageInfo { + id: number; + eventDraftId: number; + style: 'SIMPLE' | 'FANCY' | 'TRENDY'; + platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO'; + cdnUrl: string; + prompt: string; + selected: boolean; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface ImageInfo { + id: number; + eventId: string; // Changed from eventDraftId: number + style: 'SIMPLE' | 'FANCY' | 'TRENDY'; + platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO'; + cdnUrl: string; + prompt: string; + selected: boolean; + createdAt: string; + updatedAt: string; +} +``` + +### ContentInfo + +**Before:** +```typescript +interface ContentInfo { + id: number; + eventDraftId: number; + eventTitle: string; + eventDescription: string; + images: ImageInfo[]; + createdAt: string; + updatedAt: string; +} +``` + +**After:** +```typescript +interface ContentInfo { + id: number; + eventId: string; // Changed from eventDraftId: number + eventTitle: string; + eventDescription: string; + images: ImageInfo[]; + createdAt: string; + updatedAt: string; +} +``` + +--- + +## 📝 수정된 파일 목록 + +### 1. Type Definitions +- ✅ `src/shared/api/contentApi.ts` + - `GenerateImagesRequest` interface updated + - `JobInfo` interface updated + - `ImageInfo` interface updated + - `ContentInfo` interface updated + - API function signatures updated + +### 2. Components +- ✅ `src/app/(main)/events/create/steps/ContentPreviewStep.tsx` + - `EventCreationData` interface: `eventDraftId: number` → `eventDraftId: string` + - Mock data updated to use string type + - API call updated: `eventDraftId` → `eventId` + +### 3. Mock Data Files +- ✅ `public/init-mock-data.html` + - `eventDraftId: 7777` → `eventDraftId: "7777"` + +- ✅ `MOCK_DATA_SETUP.md` + - All mock data examples updated to string type + - Documentation notes added about type change + +### 4. API Routes (Next.js Proxy) +- ✅ `src/app/api/content/images/generate/route.ts` (no changes needed) +- ✅ `src/app/api/content/images/jobs/[jobId]/route.ts` (no changes needed) +- ✅ `src/app/api/content/events/[eventDraftId]/images/route.ts` + - Comment added about eventId parameter + +--- + +## 🧪 테스트 예시 + +### API 요청 예시 + +**Before:** +```json +POST /api/v1/content/images/generate +{ + "eventDraftId": 7777, + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "styles": ["SIMPLE", "FANCY", "TRENDY"], + "platforms": ["INSTAGRAM"] +} +``` + +**After:** +```json +POST /api/v1/content/images/generate +{ + "eventId": "7777", + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "styles": ["SIMPLE", "FANCY", "TRENDY"], + "platforms": ["INSTAGRAM"] +} +``` + +### localStorage Mock 데이터 + +**Before:** +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: 7777, + eventTitle: "맥주 파티 이벤트", + // ... +})); +``` + +**After:** +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "7777", // String type now + eventTitle: "맥주 파티 이벤트", + // ... +})); +``` + +--- + +## ✅ 마이그레이션 체크리스트 + +- [x] TypeScript 인터페이스 업데이트 +- [x] API 호출 코드 수정 +- [x] Mock 데이터 타입 변경 +- [x] 문서 업데이트 +- [x] 빌드 성공 확인 +- [ ] 개발 서버 테스트 +- [ ] 실제 API 연동 테스트 + +--- + +## 🔗 관련 API 문서 + +- Swagger UI: http://localhost:8084/swagger-ui/index.html +- OpenAPI Spec: http://localhost:8084/v3/api-docs + +--- + +## 📌 주의사항 + +1. **타입 주의**: `eventId`는 이제 `string` 타입입니다. 숫자로 사용하지 마세요. +2. **Mock 데이터**: localStorage에 저장할 때 문자열 타입으로 저장해야 합니다. +3. **API 호출**: 프론트엔드에서 백엔드로 전송 시 string으로 전송됩니다. +4. **하위 호환성**: 기존 number 타입 데이터는 작동하지 않으므로 localStorage를 초기화해야 합니다. + +--- + +## 🔄 롤백 방법 + +만약 이전 버전으로 돌아가야 한다면: + +1. `git revert` 또는 특정 커밋으로 복원 +2. localStorage 초기화: `localStorage.removeItem('eventCreationData')` +3. 개발 서버 재시작 diff --git a/CORS_FIX.md b/CORS_FIX.md new file mode 100644 index 0000000..de2857f --- /dev/null +++ b/CORS_FIX.md @@ -0,0 +1,221 @@ +# CORS 문제 해결 방법 + +## 문제 상황 + +프론트엔드(`http://localhost:3000`)에서 백엔드 Content API(`http://localhost:8084`)를 직접 호출하면 **CORS(Cross-Origin Resource Sharing)** 에러가 발생했습니다. + +### 에러 메시지 +``` +Network Error +AxiosError: Network Error + code: "ERR_NETWORK" +``` + +### 원인 분석 +```bash +# CORS preflight 요청 테스트 +curl -X OPTIONS http://localhost:8084/api/v1/content/images/generate \ + -H 'Origin: http://localhost:3000' \ + -H 'Access-Control-Request-Method: POST' \ + -H 'Access-Control-Request-Headers: content-type' + +# 결과: HTTP/1.1 403 Forbidden +# Invalid CORS request +``` + +백엔드 서버가 `http://localhost:3000` origin에서의 CORS 요청을 허용하지 않음. + +--- + +## 해결 방법: Next.js API Proxy + +백엔드 CORS 설정을 수정하는 대신, **Next.js API Routes를 프록시로 사용**하여 CORS 문제를 우회했습니다. + +### 아키텍처 + +``` +[Browser] + ↓ (Same-Origin Request) +[Next.js Frontend: localhost:3000] + ↓ [Next.js API Proxy: /api/content/*] + ↓ (Server-to-Server Request, CORS 무관) +[Content API Backend: localhost:8084] +``` + +### 구현 파일 + +#### 1. **이미지 생성 프록시** (`/api/content/images/generate/route.ts`) + +```typescript +export async function POST(request: NextRequest) { + const body = await request.json(); + + const response = await fetch('http://localhost:8084/api/v1/content/images/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `POST /api/content/images/generate` +- Backend: `POST http://localhost:8084/api/v1/content/images/generate` + +#### 2. **Job 상태 조회 프록시** (`/api/content/images/jobs/[jobId]/route.ts`) + +```typescript +export async function GET(request: NextRequest, { params }: { params: { jobId: string } }) { + const { jobId } = params; + + const response = await fetch(`http://localhost:8084/api/v1/content/images/jobs/${jobId}`, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `GET /api/content/images/jobs/{jobId}` +- Backend: `GET http://localhost:8084/api/v1/content/images/jobs/{jobId}` + +#### 3. **이미지 목록 조회 프록시** (`/api/content/events/[eventDraftId]/images/route.ts`) + +```typescript +export async function GET(request: NextRequest, { params }: { params: { eventDraftId: string } }) { + const { eventDraftId } = params; + const { searchParams } = new URL(request.url); + + let url = `http://localhost:8084/api/v1/content/events/${eventDraftId}/images`; + if (searchParams.get('style')) url += `?style=${searchParams.get('style')}`; + if (searchParams.get('platform')) url += `&platform=${searchParams.get('platform')}`; + + const response = await fetch(url, { + method: 'GET', + headers: { 'Content-Type': 'application/json' }, + }); + + return NextResponse.json(await response.json()); +} +``` + +**URL 매핑**: +- Frontend: `GET /api/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM` +- Backend: `GET http://localhost:8084/api/v1/content/events/{eventDraftId}/images?style=SIMPLE&platform=INSTAGRAM` + +--- + +## 클라이언트 코드 변경 + +### Before (직접 백엔드 호출 - CORS 에러 발생) + +```typescript +const CONTENT_API_BASE_URL = 'http://localhost:8084'; + +export const contentApiClient = axios.create({ + baseURL: CONTENT_API_BASE_URL, +}); + +// ❌ CORS Error +await contentApiClient.post('/api/v1/content/images/generate', request); +``` + +### After (Next.js API Proxy 사용 - CORS 우회) + +```typescript +const CONTENT_API_BASE_URL = '/api/content'; // Same-origin request + +export const contentApiClient = axios.create({ + baseURL: CONTENT_API_BASE_URL, +}); + +// ✅ Works! (Same-origin → Server-side proxy → Backend) +await contentApiClient.post('/images/generate', request); +``` + +--- + +## 장점 + +✅ **프론트엔드 수정만으로 해결**: 백엔드 CORS 설정 변경 불필요 +✅ **Same-Origin 정책 준수**: 브라우저는 같은 도메인으로 인식 +✅ **서버 간 통신**: Next.js 서버에서 백엔드 호출 (CORS 무관) +✅ **보안 강화**: 백엔드 URL을 클라이언트에 노출하지 않음 +✅ **환경 변수 활용**: `NEXT_PUBLIC_CONTENT_API_URL`로 배포 환경 대응 + +--- + +## 프로덕션 배포 시 고려사항 + +### 환경 변수 설정 + +```bash +# .env.local (개발 환경) +NEXT_PUBLIC_CONTENT_API_URL=http://localhost:8084 + +# .env.production (프로덕션 환경) +NEXT_PUBLIC_CONTENT_API_URL=https://api.production.com +``` + +### 프록시 코드에 적용 + +```typescript +const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084'; + +const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), +}); +``` + +### 타임아웃 설정 + +```typescript +const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(120000), // 120초 타임아웃 +}); +``` + +--- + +## 테스트 방법 + +### 1. 개발 서버 실행 + +```bash +npm run dev +``` + +### 2. 브라우저에서 테스트 + +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 3. 네트워크 탭 확인 + +브라우저 개발자 도구 → Network 탭에서 다음 요청 확인: + +``` +POST http://localhost:3000/api/content/images/generate (Status: 202) +GET http://localhost:3000/api/content/images/jobs/job-xxxxx (Status: 200) +GET http://localhost:3000/api/content/events/7777/images (Status: 200) +``` + +모두 **Same-Origin** 요청이므로 CORS 에러 없음! + +--- + +## 참고 자료 + +- [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) +- [CORS (MDN)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) +- [Proxy Pattern](https://en.wikipedia.org/wiki/Proxy_pattern) diff --git a/FIX_EVENTID_MISMATCH.md b/FIX_EVENTID_MISMATCH.md new file mode 100644 index 0000000..f10105c --- /dev/null +++ b/FIX_EVENTID_MISMATCH.md @@ -0,0 +1,182 @@ +# EventId 불일치 문제 해결 + +## 문제 상황 + +사용자가 이미지 생성 페이지에서 스타일 1 카드에 이미지가 표시되지 않고 플레이스홀더만 보이는 문제가 발생했습니다. + +### 스크린샷 분석 +- **스타일 1 (SIMPLE)**: 플레이스홀더만 표시 (아이콘 + 제목 + 경품) +- **스타일 2 (FANCY)**: 실제 이미지 표시 ✅ +- **스타일 3 (TRENDY)**: 실제 이미지 표시 ✅ + +## 근본 원인 + +API 분석 결과, 데이터베이스에 저장된 이미지와 Mock 데이터의 eventId가 일치하지 않았습니다: + +```bash +# Mock 데이터 eventId +"7777" + +# 데이터베이스 실제 eventId +curl http://localhost:8084/api/v1/content/events/7777/images +→ Response: [] (빈 배열) + +# 데이터베이스에 존재하는 eventId +- "Tst12131": SIMPLE 이미지 1개 +- "1761634317010": SIMPLE, FANCY, TRENDY 각 2개씩 총 6개 +- null: SIMPLE 이미지 1개 +``` + +**결론**: Mock 데이터의 eventId "7777"로는 어떤 이미지도 조회되지 않았습니다. + +## 해결 방법 + +데이터베이스에 이미 존재하는 이미지가 있는 eventId로 Mock 데이터를 변경했습니다. + +### 변경된 eventId +```javascript +// Before +eventDraftId: "7777" + +// After +eventDraftId: "1761634317010" +``` + +**선택 이유**: +- SIMPLE, FANCY, TRENDY 3가지 스타일 모두 이미지 보유 +- 각 스타일별로 2개씩 총 6개의 이미지 존재 +- INSTAGRAM 플랫폼 이미지 존재 + +## 수정된 파일 + +### 1. ContentPreviewStep.tsx +**위치**: `src/app/(main)/events/create/steps/ContentPreviewStep.tsx:109` + +```typescript +const mockData: EventCreationData = { + eventDraftId: "1761634317010", // Changed from "7777" + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; +``` + +### 2. init-mock-data.html +**위치**: `public/init-mock-data.html:121`, `public/init-mock-data.html:168` + +```html + +1761634317010 + + + +``` + +### 3. QUICK_TEST.md +**위치**: `QUICK_TEST.md` (전체 문서) + +- Mock 데이터 예시의 eventId 변경 +- API 확인 예시의 eventId 변경 +- 디버깅 로그 예시 업데이트 + +### 4. MOCK_DATA_SETUP.md +**위치**: `MOCK_DATA_SETUP.md` (전체 문서) + +- Mock 데이터 구조 예시 업데이트 +- 테스트 시나리오 eventId 변경 +- 참고 사항 추가: "1761634317010은 데이터베이스에 이미 생성된 이미지가 있는 eventId" + +## 빌드 검증 + +```bash +npm run build +``` + +✅ **성공**: TypeScript 타입 검증 통과, 빌드 완료 + +경고 사항: +- `loadingProgress`, `setLoadingProgress` 미사용 변수 (기능에 영향 없음) +- 기타 ESLint 경고 (기존 코드, 금번 수정과 무관) + +## 테스트 방법 + +### 1. localStorage 초기화 +브라우저 콘솔에서 기존 데이터 삭제: +```javascript +localStorage.removeItem('eventCreationData'); +``` + +### 2. 개발 서버 실행 +```bash +npm run dev +``` + +### 3. 페이지 접속 +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 4. 예상 결과 +- ✅ 스타일 1 (SIMPLE): 실제 이미지 표시 +- ✅ 스타일 2 (FANCY): 실제 이미지 표시 +- ✅ 스타일 3 (TRENDY): 실제 이미지 표시 +- ✅ 3개 스타일 모두 "크게보기" 버튼 활성화 + +### 5. 콘솔 로그 확인 +``` +📥 Loading generated images for event: 1761634317010 +✅ Images loaded from API: 6 [...] +📸 Processing image 1: { id: X, style: 'SIMPLE', ... } + ✅ Selected as latest SIMPLE image +📸 Processing image 2: { id: Y, style: 'FANCY', ... } + ✅ Selected as latest FANCY image +📸 Processing image 3: { id: Z, style: 'TRENDY', ... } + ✅ Selected as latest TRENDY image +🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 } +✅ 이미지 로드 완료! +🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, ... } +✅ SIMPLE image loaded successfully +``` + +## 추가 참고 사항 + +### 새로운 이벤트 테스트 시 +새로운 eventId로 이미지를 생성하려면: + +1. localStorage에 새로운 eventId 설정 +2. "이미지 재생성" 버튼 클릭 +3. 약 2초 후 자동으로 생성된 이미지 로드 + +### Mock 데이터 변경 방법 +`public/init-mock-data.html` 페이지 사용: +``` +http://localhost:3000/init-mock-data.html +``` + +또는 브라우저 콘솔에서 직접 설정: +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "1761634317010", + eventTitle: "...", + // ... +})); +``` + +## 결론 + +EventId 불일치 문제를 해결하여 모든 스타일 카드에서 실제 이미지가 정상적으로 표시됩니다. + +**핵심 변경**: Mock 데이터의 eventId를 데이터베이스에 존재하는 "1761634317010"으로 변경 + +**영향 범위**: +- 개발/테스트 환경의 Mock 데이터만 영향 +- 실제 운영 환경에서는 Channel Step API에서 제공하는 실제 eventId 사용 +- 코드 로직 변경 없음, 데이터만 변경 diff --git a/MOCK_DATA_SETUP.md b/MOCK_DATA_SETUP.md new file mode 100644 index 0000000..f438436 --- /dev/null +++ b/MOCK_DATA_SETUP.md @@ -0,0 +1,149 @@ +# Mock 데이터 설정 가이드 + +AI 이미지 생성 기능을 테스트하기 위해 localStorage에 mock 데이터를 설정하는 방법입니다. + +## 🚀 빠른 시작 + +### 방법 1: 웹 인터페이스 사용 (권장) + +1. 개발 서버 실행 +```bash +npm run dev +``` + +2. 브라우저에서 mock 데이터 초기화 페이지 열기 +``` +http://localhost:3000/init-mock-data.html +``` + +3. "LocalStorage에 저장하기" 버튼 클릭 + +4. 이미지 생성 페이지로 이동 +``` +http://localhost:3000/events/create?step=contentPreview +``` + +### 방법 2: 브라우저 콘솔 사용 + +1. 개발 서버 실행 후 브라우저에서 아무 페이지나 열기 + +2. F12 또는 Cmd+Option+I로 개발자 도구 열기 + +3. Console 탭에서 다음 코드 실행: + +```javascript +const mockEventData = { + eventDraftId: "1761634317010", // String type (existing eventId with images) + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; + +localStorage.setItem('eventCreationData', JSON.stringify(mockEventData)); +console.log('✅ Mock 데이터 저장 완료!'); +``` + +4. 이미지 생성 페이지로 이동 + +### 방법 3: 테스트 HTML 파일 사용 + +프로젝트 루트의 `test-localstorage.html` 파일을 브라우저에서 직접 열기: + +```bash +open test-localstorage.html +``` + +## 📊 Mock 데이터 구조 + +```json +{ + "eventDraftId": "1761634317010", + "eventTitle": "맥주 파티 이벤트", + "eventDescription": "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + "industry": "음식점", + "location": "강남", + "trends": ["파티", "맥주", "생맥주"], + "prize": "생맥주 1잔" +} +``` + +**참고**: +- `eventDraftId`는 API 변경으로 인해 `string` 타입입니다. +- `"1761634317010"`은 데이터베이스에 이미 생성된 이미지가 있는 eventId입니다. + +## 🧪 테스트 시나리오 + +### 시나리오 1: 전체 이미지 생성 플로우 + +1. Mock 데이터 설정 +2. `/events/create?step=contentPreview` 접속 +3. 자동으로 AI 이미지 생성 시작 +4. 3가지 스타일(SIMPLE, FANCY, TRENDY) 확인 +5. 스타일 선택 후 다음 단계 진행 + +### 시나리오 2: 다양한 이벤트 데이터 테스트 + +다른 업종/지역/트렌드로 테스트: + +```javascript +// 카페 이벤트 (새로운 이미지 생성 필요) +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "test-cafe-001", + eventTitle: "커피 할인 이벤트", + eventDescription: "신메뉴 출시 기념 30% 할인", + industry: "카페", + location: "홍대", + trends: ["커피", "할인", "신메뉴"], + prize: "아메리카노 1잔" +})); +``` + +```javascript +// 뷰티 이벤트 (새로운 이미지 생성 필요) +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "test-beauty-001", + eventTitle: "봄맞이 피부관리 이벤트", + eventDescription: "봄맞이 특별 케어 프로그램", + industry: "뷰티", + location: "강남", + trends: ["피부관리", "봄", "케어"], + prize: "페이셜 케어 1회" +})); +``` + +## 🔍 디버깅 + +### localStorage 데이터 확인 + +```javascript +// 현재 저장된 데이터 확인 +const data = localStorage.getItem('eventCreationData'); +console.log(JSON.parse(data)); +``` + +### localStorage 데이터 삭제 + +```javascript +localStorage.removeItem('eventCreationData'); +console.log('✅ 데이터 삭제 완료'); +``` + +## ⚠️ 주의사항 + +1. **같은 도메인**: localStorage는 도메인별로 분리되므로, 같은 localhost:3000에서 설정해야 합니다. + +2. **브라우저 제한**: 시크릿 모드에서는 localStorage가 제한될 수 있습니다. + +3. **데이터 유지**: 브라우저를 닫아도 localStorage 데이터는 유지됩니다. 새로운 테스트 시 삭제 후 진행하세요. + +## 🎯 실제 API 연동 후 + +Channel Step API가 구현되면 이 mock 데이터 설정은 불필요하며, +실제 플로우에서 자동으로 데이터가 저장됩니다: + +``` +Objective → Recommendation → Channel (여기서 localStorage 저장) → ContentPreview (이미지 생성) +``` diff --git a/QUICK_TEST.md b/QUICK_TEST.md new file mode 100644 index 0000000..8a1b748 --- /dev/null +++ b/QUICK_TEST.md @@ -0,0 +1,145 @@ +# 🚀 AI 이미지 생성 빠른 테스트 가이드 + +## ⚡ 가장 빠른 방법 (기존 이미지 확인) + +```bash +# 1. 개발 서버 실행 +npm run dev + +# 2. 브라우저에서 바로 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +✨ **끝!** 자동으로 Mock 데이터(eventId: "1761634317010")가 생성되고 기존 생성된 이미지를 불러옵니다. + +💡 **이미지가 없을 경우**: "이미지 생성하기" 버튼을 클릭하면 새로운 이미지를 생성합니다. + +--- + +## 📋 커스텀 데이터로 테스트 (선택사항) + +### 1단계: 개발 서버 실행 + +```bash +npm run dev +``` + +### 2단계: Mock 데이터 설정 (3가지 방법 중 선택) + +### ✨ 방법 A: 웹 UI 사용 (가장 쉬움!) + +브라우저에서 열기: +``` +http://localhost:3000/init-mock-data.html +``` + +"LocalStorage에 저장하기" 버튼 클릭 → 완료! + +--- + +### 방법 B: 브라우저 콘솔 사용 + +1. `http://localhost:3000` 접속 +2. F12 (개발자 도구) → Console 탭 +3. 다음 코드 복사 & 붙여넣기: + +```javascript +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: "1761634317010", + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +})); +``` + +--- + +### 방법 C: HTML 파일 직접 열기 + +```bash +open test-localstorage.html +``` + +## 3단계: 이미지 생성 페이지 접속 + +브라우저에서 열기: +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +## 4단계: 자동 실행 확인 ✅ + +1. 페이지 로딩되면 자동으로 이미지 생성 시작 +2. 로딩 스피너와 진행률 확인 +3. 약 60초 후 3가지 스타일 이미지 완성 + - 스타일 1: 심플 + - 스타일 2: 화려 + - 스타일 3: 트렌디 + +## 예상 결과 + +### 이미지가 있는 경우 +- ✅ **즉시 표시**: 로딩 후 바로 이미지 미리보기 화면 +- ✅ **3개 스타일 이미지**: SIMPLE, FANCY, TRENDY 각각 최신 이미지 표시 +- ✅ **이미지 선택**: 라디오 버튼으로 원하는 스타일 선택 +- ✅ **재생성 버튼**: "이미지 재생성" 버튼으로 새로운 이미지 생성 가능 +- ✅ **크게보기**: 각 이미지 클릭 시 전체화면 미리보기 + +### 이미지가 없는 경우 +- ⚠️ **에러 메시지**: "생성된 이미지가 없습니다. 이미지를 먼저 생성해주세요." +- 🔄 **생성 버튼**: "이미지 생성하기" 버튼 클릭 +- ⏳ **생성 대기**: API 요청 후 2초 뒤 자동으로 이미지 조회 +- ✅ **이미지 표시**: 생성 완료된 이미지 자동 표시 + +## 문제 해결 + +### ~~"이벤트 정보를 찾을 수 없습니다" 에러~~ +→ ✅ **해결됨!** 이제 자동으로 Mock 데이터가 생성됩니다. + +### ~~Network Error / CORS 에러~~ +→ ✅ **해결됨!** Next.js API proxy를 통해 CORS 문제 우회 +→ 프론트엔드가 `/api/content/*` → 백엔드 `localhost:8084` 로 자동 프록시 + +### 이미지 생성 실패 +→ Content API (localhost:8084) 실행 여부 확인 +→ 터미널에서 확인: `curl http://localhost:8084/api/v1/content/events/7777/images` + +### 이미지가 표시되지 않음 +→ 네트워크 탭에서 CDN URL 로드 상태 확인 +→ Azure Blob Storage 접근 권한 확인 + +## API 확인 + +```bash +# 이벤트 1761634317010의 이미지 확인 +curl http://localhost:8084/api/v1/content/events/1761634317010/images + +# 프론트엔드 프록시를 통한 확인 (개발 서버 실행 중) +curl http://localhost:3000/api/content/events/1761634317010/images +``` + +## 디버깅 + +브라우저 개발자 도구 (F12) → Console 탭에서 다음 로그 확인: + +``` +📥 Loading generated images for event: 1761634317010 +✅ Images loaded from API: 6 [...] +📸 Processing image 1: { id: 1, style: 'SIMPLE', platform: 'INSTAGRAM', ... } + ✅ Selected as latest SIMPLE image +📸 Processing image 2: { id: 3, style: 'FANCY', platform: 'INSTAGRAM', ... } + ✅ Selected as latest FANCY image +📸 Processing image 3: { id: 5, style: 'TRENDY', platform: 'INSTAGRAM', ... } + ✅ Selected as latest TRENDY image +🎨 Image map created with entries: { SIMPLE: 'YES ✅', FANCY: 'YES ✅', TRENDY: 'YES ✅', totalSize: 3 } +✅ 이미지 로드 완료! 미리보기 화면으로 전환합니다. +🖼️ Rendering SIMPLE: { hasImage: true, imageDataExists: true, cdnUrl: 'https://blob...' } +✅ SIMPLE image loaded successfully +``` + +--- + +더 자세한 내용은 `MOCK_DATA_SETUP.md` 참조 diff --git a/TEST_URLS.md b/TEST_URLS.md new file mode 100644 index 0000000..df456ed --- /dev/null +++ b/TEST_URLS.md @@ -0,0 +1,117 @@ +# 🔗 AI 이미지 생성 테스트 URL 가이드 + +## ✅ 올바른 URL + +### ContentPreview Step 직접 접속 +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +또는 간단하게: +``` +http://localhost:3000/events/create?step=contentPreview +``` + +### Mock 데이터 설정 페이지 +``` +http://localhost:3000/init-mock-data.html +``` + +## 📝 URL 파라미터 설명 + +- `event-creation.step=contentPreview` - Funnel의 step을 contentPreview로 설정 +- `step=contentPreview` - 간단한 형식 (funnel id가 event-creation일 때) + +## 🎯 전체 플로우 테스트 URL + +### 1. 시작 (Objective Step) +``` +http://localhost:3000/events/create +``` + +### 2. Channel Step까지 진행 후 +``` +http://localhost:3000/events/create?event-creation.step=channel +``` + +### 3. ContentPreview Step (이미지 생성) +``` +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +## 💡 자동 Mock 데이터 생성 + +이제 `contentPreview` 페이지에 직접 접속하면: + +1. ✅ localStorage 확인 +2. ✅ 데이터 없으면 자동으로 Mock 데이터 생성 +3. ✅ 즉시 AI 이미지 생성 시작 + +**더 이상 수동으로 Mock 데이터를 설정할 필요가 없습니다!** + +## 🧪 테스트 시나리오 + +### 시나리오 1: 가장 빠른 테스트 +```bash +# 1. 개발 서버 실행 +npm run dev + +# 2. 브라우저에서 바로 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` +→ 자동으로 Mock 데이터 생성 & 이미지 생성 시작! + +### 시나리오 2: 커스텀 데이터로 테스트 +```bash +# 1. Mock 데이터 설정 페이지 열기 +http://localhost:3000/init-mock-data.html + +# 2. 원하는 데이터 입력 후 저장 + +# 3. ContentPreview 접속 +http://localhost:3000/events/create?event-creation.step=contentPreview +``` + +### 시나리오 3: 전체 플로우 테스트 +```bash +# 1. 처음부터 시작 +http://localhost:3000/events/create + +# 2. Objective 선택 + +# 3. Recommendation 확인 + +# 4. Channel 선택 (SNS, 우리동네TV, 지니TV 중 하나) + +# 5. 자동으로 ContentPreview로 이동하며 이미지 생성 시작 +``` + +## 🐛 문제 해결 + +### "이벤트 정보를 찾을 수 없습니다" 에러 +→ 이제 이 에러는 발생하지 않습니다! 자동으로 Mock 데이터가 생성됩니다. + +### 이미지 생성 실패 +```bash +# Content API 서버 확인 +curl http://localhost:8084/api/v1/content/events/7777/images + +# API 서버가 꺼져있다면 실행 필요 +``` + +### 다른 이벤트 ID로 테스트하고 싶을 때 +```javascript +// 브라우저 콘솔에서 +localStorage.setItem('eventCreationData', JSON.stringify({ + eventDraftId: 8888, // 다른 ID + eventTitle: "커피 할인 이벤트", + eventDescription: "신메뉴 출시 기념", + industry: "카페", + location: "홍대", + trends: ["커피", "할인"], + prize: "아메리카노 1잔" +})); + +// 페이지 새로고침 +location.reload(); +``` diff --git a/next.config.js b/next.config.js index 03b1343..8bd207c 100644 --- a/next.config.js +++ b/next.config.js @@ -6,7 +6,7 @@ const nextConfig = { emotion: true, }, images: { - domains: ['localhost'], + domains: ['localhost', 'blobkteventstorage.blob.core.windows.net'], formats: ['image/webp', 'image/avif'], }, env: { diff --git a/public/init-mock-data.html b/public/init-mock-data.html new file mode 100644 index 0000000..9546666 --- /dev/null +++ b/public/init-mock-data.html @@ -0,0 +1,205 @@ + + + + + + Mock 데이터 초기화 + + + +
+

🎨 Mock 데이터 초기화

+

이벤트 생성 테스트를 위한 샘플 데이터

+ +
+

📋 저장될 데이터

+
+ 이벤트 ID: + 1761634317010 +
+
+ 제목: + 맥주 파티 이벤트 +
+
+ 설명: + 강남에서 열리는 신나는 맥주 파티 +
+
+ 업종: + 음식점 +
+
+ 지역: + 강남 +
+
+ 트렌드: + 파티, 맥주, 생맥주 +
+
+ 경품: + 생맥주 1잔 +
+
+ + + +
+ ✅ Mock 데이터가 성공적으로 저장되었습니다!
+ 이제 이벤트 생성 페이지로 이동할 수 있습니다. +
+ + +
+ + + + diff --git a/setup-mock-data.js b/setup-mock-data.js new file mode 100644 index 0000000..ccf8745 --- /dev/null +++ b/setup-mock-data.js @@ -0,0 +1,17 @@ +// Mock 데이터를 localStorage에 저장하는 스크립트 + +const mockEventData = { + eventDraftId: 7777, + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" +}; + +// localStorage에 저장 +localStorage.setItem('eventCreationData', JSON.stringify(mockEventData)); + +console.log('✅ Mock 데이터가 localStorage에 저장되었습니다:'); +console.log(JSON.stringify(mockEventData, null, 2)); diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index 1595442..a83b41d 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -15,12 +15,16 @@ export type BudgetLevel = 'low' | 'medium' | 'high'; export type EventMethod = 'online' | 'offline'; export interface EventData { + eventDraftId?: number; objective?: EventObjective; recommendation?: { budget: BudgetLevel; method: EventMethod; title: string; prize: string; + description?: string; + industry?: string; + location?: string; participationMethod: string; expectedParticipants: number; estimatedCost: number; @@ -28,6 +32,7 @@ export interface EventData { }; contentPreview?: { imageStyle: string; + images?: any[]; }; contentEdit?: { title: string; @@ -89,6 +94,18 @@ export default function EventCreatePage() { ); if (needsContent) { + // localStorage에 이벤트 정보 저장 + const eventData = { + eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성 + eventTitle: context.recommendation?.title || '', + eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '', + industry: context.recommendation?.industry || '', + location: context.recommendation?.location || '', + trends: [], // 필요시 context에서 추가 + prize: context.recommendation?.prize || '', + }; + localStorage.setItem('eventCreationData', JSON.stringify(eventData)); + history.push('contentPreview', { ...context, channels }); } else { history.push('approval', { ...context, channels }); @@ -101,12 +118,10 @@ export default function EventCreatePage() { )} contentPreview={({ context, history }) => ( { + onNext={(imageStyle, images) => { history.push('contentEdit', { ...context, - contentPreview: { imageStyle }, + contentPreview: { imageStyle, images }, }); }} onSkip={() => { diff --git a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx index 5a2f7df..a0fc4f7 100644 --- a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx +++ b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx @@ -12,8 +12,11 @@ import { IconButton, Dialog, Grid, + Alert, } from '@mui/material'; -import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material'; +import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material'; +import { contentApi, ImageInfo } from '@/shared/api/contentApi'; +import Image from 'next/image'; // 디자인 시스템 색상 const colors = { @@ -34,7 +37,7 @@ const colors = { }; interface ImageStyle { - id: string; + id: 'SIMPLE' | 'FANCY' | 'TRENDY'; name: string; gradient?: string; icon: string; @@ -43,19 +46,19 @@ interface ImageStyle { const imageStyles: ImageStyle[] = [ { - id: 'simple', + id: 'SIMPLE', name: '스타일 1: 심플', icon: 'celebration', }, { - id: 'fancy', + id: 'FANCY', name: '스타일 2: 화려', gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', icon: 'auto_awesome', textColor: 'white', }, { - id: 'trendy', + id: 'TRENDY', name: '스타일 3: 트렌디', gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)', icon: 'trending_up', @@ -64,50 +67,230 @@ const imageStyles: ImageStyle[] = [ ]; interface ContentPreviewStepProps { - title: string; - prize: string; - onNext: (imageStyle: string) => void; + onNext: (imageStyle: string, images: ImageInfo[]) => void; onSkip: () => void; onBack: () => void; } +interface EventCreationData { + eventDraftId: string; // Changed from number to string + eventTitle: string; + eventDescription: string; + industry: string; + location: string; + trends: string[]; + prize: string; +} + export default function ContentPreviewStep({ - title, - prize, onNext, onSkip, onBack, }: ContentPreviewStepProps) { const [loading, setLoading] = useState(true); - const [selectedStyle, setSelectedStyle] = useState(null); + const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null); const [fullscreenOpen, setFullscreenOpen] = useState(false); - const [fullscreenStyle, setFullscreenStyle] = useState(null); + const [fullscreenImage, setFullscreenImage] = useState(null); + const [generatedImages, setGeneratedImages] = useState>(new Map()); + const [error, setError] = useState(null); + const [loadingProgress, setLoadingProgress] = useState(0); + const [loadingMessage, setLoadingMessage] = useState('이미지 생성 요청 중...'); + const [eventData, setEventData] = useState(null); useEffect(() => { - // AI 이미지 생성 시뮬레이션 - const timer = setTimeout(() => { - setLoading(false); - }, 5000); + // localStorage에서 이벤트 데이터 읽기 + const storedData = localStorage.getItem('eventCreationData'); + if (storedData) { + const data: EventCreationData = JSON.parse(storedData); + setEventData(data); - return () => clearTimeout(timer); + // 먼저 이미지 조회 시도 + loadImages(data).then((hasImages) => { + // 이미지가 없으면 자동으로 생성 + if (!hasImages) { + console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...'); + handleGenerateImagesAuto(data); + } + }); + } else { + // Mock 데이터가 없으면 자동으로 설정 + const mockData: EventCreationData = { + eventDraftId: "1761634317010", // Changed to string + eventTitle: "맥주 파티 이벤트", + eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", + industry: "음식점", + location: "강남", + trends: ["파티", "맥주", "생맥주"], + prize: "생맥주 1잔" + }; + + console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.'); + localStorage.setItem('eventCreationData', JSON.stringify(mockData)); + setEventData(mockData); + loadImages(mockData); + } + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleStyleSelect = (styleId: string) => { + const loadImages = async (data: EventCreationData): Promise => { + try { + setError(null); + + console.log('📥 Loading images for event:', data.eventDraftId); + const images = await contentApi.getImages(data.eventDraftId); + console.log('✅ Images loaded from API:', images.length, images); + + if (!images || images.length === 0) { + console.warn('⚠️ No images found.'); + return false; // 이미지 없음 + } + + const imageMap = new Map(); + + // 각 스타일별로 가장 최신 이미지만 선택 (createdAt 기준) + images.forEach((image, index) => { + console.log(`📸 Processing image ${index + 1}:`, { + id: image.id, + eventId: image.eventId, + style: image.style, + platform: image.platform, + cdnUrl: image.cdnUrl?.substring(0, 50) + '...', + createdAt: image.createdAt, + }); + + if (image.platform === 'INSTAGRAM') { + const existing = imageMap.get(image.style); + if (!existing || new Date(image.createdAt) > new Date(existing.createdAt)) { + console.log(` ✅ Selected as latest ${image.style} image`); + imageMap.set(image.style, image); + } else { + console.log(` ⏭️ Skipped (older than existing ${image.style} image)`); + } + } else { + console.log(` ⏭️ Skipped (platform: ${image.platform})`); + } + }); + + console.log('🎨 Image map created with entries:', { + SIMPLE: imageMap.has('SIMPLE') ? 'YES ✅' : 'NO ❌', + FANCY: imageMap.has('FANCY') ? 'YES ✅' : 'NO ❌', + TRENDY: imageMap.has('TRENDY') ? 'YES ✅' : 'NO ❌', + totalSize: imageMap.size, + }); + + console.log('🖼️ Image map details:', Array.from(imageMap.entries()).map(([style, img]) => ({ + style, + id: img.id, + eventId: img.eventId, + cdnUrl: img.cdnUrl?.substring(0, 60) + '...', + }))); + + setGeneratedImages(imageMap); + console.log('✅ Images loaded successfully!'); + return true; // 이미지 있음 + } catch (err) { + console.error('❌ Load images error:', err); + // API 에러는 polling에서 무시 (계속 시도) + return false; + } + }; + + const handleStyleSelect = (styleId: 'SIMPLE' | 'FANCY' | 'TRENDY') => { setSelectedStyle(styleId); }; - const handlePreview = (style: ImageStyle, e: React.MouseEvent) => { + const handlePreview = (image: ImageInfo, e: React.MouseEvent) => { e.stopPropagation(); - setFullscreenStyle(style); + setFullscreenImage(image); setFullscreenOpen(true); }; const handleNext = () => { if (selectedStyle) { - onNext(selectedStyle); + const allImages = Array.from(generatedImages.values()); + onNext(selectedStyle, allImages); } }; + const handleGenerateImagesAuto = async (data: EventCreationData) => { + try { + setLoading(true); + setError(null); + setLoadingProgress(0); + setLoadingMessage('이미지 생성 요청 중...'); + + console.log('🎨 Auto-generating images for event:', data.eventDraftId); + + // 이미지 생성 요청 (202 Accepted 응답만 확인) + await contentApi.generateImages({ + eventId: data.eventDraftId, + eventTitle: data.eventTitle, + eventDescription: data.eventDescription, + industry: data.industry, + location: data.location, + trends: data.trends, + styles: ['SIMPLE', 'FANCY', 'TRENDY'], + platforms: ['INSTAGRAM'], + }); + + console.log('✅ Image generation request accepted (202)'); + console.log('⏳ AI 이미지 생성 중... 약 60초 소요됩니다.'); + + setLoadingProgress(10); + setLoadingMessage('AI가 이미지를 생성하고 있어요...'); + + // 생성 완료까지 대기 (polling) + let attempts = 0; + const maxAttempts = 30; // 최대 60초 (2초 * 30회) + + const pollImages = async () => { + attempts++; + console.log(`🔄 이미지 확인 시도 ${attempts}/${maxAttempts}...`); + + // 진행률 업데이트 (10% ~ 90%) + const progress = Math.min(10 + (attempts / maxAttempts) * 80, 90); + setLoadingProgress(progress); + + // 단계별 메시지 업데이트 + if (attempts < 10) { + setLoadingMessage('AI가 이미지를 생성하고 있어요...'); + } else if (attempts < 20) { + setLoadingMessage('스타일을 적용하고 있어요...'); + } else { + setLoadingMessage('거의 완료되었어요...'); + } + + const hasImages = await loadImages(data); + + if (hasImages) { + console.log('✅ 이미지 생성 완료!'); + setLoadingProgress(100); + setLoadingMessage('이미지 생성 완료!'); + setTimeout(() => setLoading(false), 500); // 100% 잠깐 보여주기 + } else if (attempts < maxAttempts) { + // 2초 후 다시 시도 + setTimeout(pollImages, 2000); + } else { + console.warn('⚠️ 이미지 생성 시간 초과. "이미지 재생성" 버튼을 클릭하세요.'); + setError('이미지 생성이 완료되지 않았습니다. 잠시 후 "이미지 재생성" 버튼을 클릭해주세요.'); + setLoading(false); + } + }; + + // 첫 번째 확인은 5초 후 시작 (생성 시작 시간 고려) + setTimeout(pollImages, 5000); + } catch (err) { + console.error('❌ Image generation request error:', err); + setError('이미지 생성 요청에 실패했습니다.'); + setLoading(false); + } + }; + + const handleGenerateImages = async () => { + if (!eventData) return; + handleGenerateImagesAuto(eventData); + }; + if (loading) { return ( @@ -121,7 +304,7 @@ export default function ContentPreviewStep({
- + {/* 그라데이션 스피너 */} - - AI 이미지 생성 중 - + + {/* 진행률 바 */} + + + + {loadingMessage} + + + {Math.round(loadingProgress)}% + + + + + + + + - 딥러닝 모델이 이벤트에 어울리는 -
- 이미지를 생성하고 있어요... -
- - 예상 시간: 5초 + {generatedImages.size > 0 ? ( + <> + 생성된 이미지를 확인하고 있어요 +
+ 잠시만 기다려주세요! + + ) : ( + <> + AI가 이벤트에 맞는 이미지를 생성하고 있어요 +
+ 약 60초 정도 소요됩니다 + + )}
+ {error && ( + + {error} + + + )}
@@ -179,7 +414,18 @@ export default function ContentPreviewStep({ } return ( - + {/* Header */} @@ -191,11 +437,35 @@ export default function ContentPreviewStep({ + + {generatedImages.size > 0 && ( + + ✨ 생성된 이미지를 확인하고 스타일을 선택하세요 + + )} + + + 이벤트에 어울리는 스타일을 선택하세요 - handleStyleSelect(e.target.value)}> + handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}> {imageStyles.map((style) => ( @@ -237,46 +507,82 @@ export default function ContentPreviewStep({ sx={{ width: '100%', aspectRatio: '1 / 1', - background: style.gradient || colors.gray[100], - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - justifyContent: 'center', - p: 6, - textAlign: 'center', + position: 'relative', + overflow: 'hidden', + bgcolor: colors.gray[100], }} > - - {style.icon} - - - {title} - - - {prize} - + {(() => { + const hasImage = generatedImages.has(style.id); + const imageData = generatedImages.get(style.id); + console.log(`🖼️ Rendering ${style.id}:`, { + hasImage, + imageDataExists: !!imageData, + fullCdnUrl: imageData?.cdnUrl, + mapSize: generatedImages.size, + mapKeys: Array.from(generatedImages.keys()), + }); + return hasImage && imageData ? ( + {style.name} console.log(`✅ ${style.id} image loaded successfully from:`, imageData.cdnUrl)} + onError={(e) => { + console.error(`❌ ${style.id} image load error:`, e); + console.error(` Failed URL:`, imageData.cdnUrl); + }} + /> + ) : ( + + + {style.icon} + + + {eventData?.eventTitle || '이벤트'} + + + {eventData?.prize || '경품'} + + + ); + })()} {/* 크게보기 버튼 */} @@ -284,7 +590,13 @@ export default function ContentPreviewStep({ + + + + +
+ ✅ localStorage에 저장되었습니다!
+ 이제 이벤트 생성 플로우에서 channel → contentPreview로 이동하면
+ 자동으로 AI 이미지 생성이 시작됩니다. +
+ +
+ 현재 localStorage 데이터: +
없음
+
+ + + + + From c9614263c09a17f9034280c60093488a4d912c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B8=EC=9B=90?= Date: Wed, 29 Oct 2025 13:49:45 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20AI=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=99=84=EC=A0=84=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AI 서비스 API 클라이언트 추가 (aiApi.ts) - Event 서비스 API 클라이언트 추가 (eventApi.ts) - RecommendationStep에서 실제 API 호출로 변경 - Job 폴링 메커니즘 구현 (5초 간격) - ContentPreviewStep의 Mock 데이터 제거 - Props를 통한 eventId 전달 구조 개선 - ApprovalStep의 타입 오류 수정 - 모든 Mock/Static 데이터 제거 완료 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/app/(main)/events/create/page.tsx | 69 ++- .../events/create/steps/ApprovalStep.tsx | 14 +- .../create/steps/ContentPreviewStep.tsx | 48 +- .../create/steps/RecommendationStep.tsx | 550 ++++++++++++------ src/shared/api/aiApi.ts | 178 ++++++ src/shared/api/eventApi.ts | 329 +++++++++++ src/shared/api/index.ts | 6 +- 7 files changed, 956 insertions(+), 238 deletions(-) create mode 100644 src/shared/api/aiApi.ts create mode 100644 src/shared/api/eventApi.ts diff --git a/src/app/(main)/events/create/page.tsx b/src/app/(main)/events/create/page.tsx index a83b41d..1fa7006 100644 --- a/src/app/(main)/events/create/page.tsx +++ b/src/app/(main)/events/create/page.tsx @@ -18,17 +18,43 @@ export interface EventData { eventDraftId?: number; objective?: EventObjective; recommendation?: { - budget: BudgetLevel; - method: EventMethod; - title: string; - prize: string; - description?: string; - industry?: string; - location?: string; - participationMethod: string; - expectedParticipants: number; - estimatedCost: number; - roi: number; + recommendation: { + optionNumber: number; + concept: string; + title: string; + description: string; + targetAudience: string; + duration: { + recommendedDays: number; + recommendedPeriod?: string; + }; + mechanics: { + type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO'; + details: string; + }; + promotionChannels: string[]; + estimatedCost: { + min: number; + max: number; + breakdown?: { + material?: number; + promotion?: number; + discount?: number; + }; + }; + expectedMetrics: { + newCustomers: { min: number; max: number }; + repeatVisits?: { min: number; max: number }; + revenueIncrease: { min: number; max: number }; + roi: { min: number; max: number }; + socialEngagement?: { + estimatedPosts: number; + estimatedReach: number; + }; + }; + differentiator: string; + }; + eventId: string; }; contentPreview?: { imageStyle: string; @@ -96,13 +122,13 @@ export default function EventCreatePage() { if (needsContent) { // localStorage에 이벤트 정보 저장 const eventData = { - eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성 - eventTitle: context.recommendation?.title || '', - eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '', - industry: context.recommendation?.industry || '', - location: context.recommendation?.location || '', - trends: [], // 필요시 context에서 추가 - prize: context.recommendation?.prize || '', + eventDraftId: context.recommendation?.eventId || String(Date.now()), // eventId 사용 + eventTitle: context.recommendation?.recommendation.title || '', + eventDescription: context.recommendation?.recommendation.description || '', + industry: '', + location: '', + trends: context.recommendation?.recommendation.promotionChannels || [], + prize: '', }; localStorage.setItem('eventCreationData', JSON.stringify(eventData)); @@ -118,6 +144,9 @@ export default function EventCreatePage() { )} contentPreview={({ context, history }) => ( { history.push('contentEdit', { ...context, @@ -134,8 +163,8 @@ export default function EventCreatePage() { )} contentEdit={({ context, history }) => ( { history.push('approval', { ...context, contentEdit }); }} diff --git a/src/app/(main)/events/create/steps/ApprovalStep.tsx b/src/app/(main)/events/create/steps/ApprovalStep.tsx index 465029e..2a349db 100644 --- a/src/app/(main)/events/create/steps/ApprovalStep.tsx +++ b/src/app/(main)/events/create/steps/ApprovalStep.tsx @@ -120,7 +120,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > - {eventData.recommendation?.title || '이벤트 제목'} + {eventData.recommendation?.recommendation.title || '이벤트 제목'} @@ -158,7 +158,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS textShadow: '0px 2px 4px rgba(0,0,0,0.15)', }} > - {eventData.recommendation?.expectedParticipants || 0} + {eventData.recommendation?.recommendation.expectedMetrics.newCustomers.max || 0} - {((eventData.recommendation?.estimatedCost || 0) / 10000).toFixed(0)} + {((eventData.recommendation?.recommendation.estimatedCost.max || 0) / 10000).toFixed(0)} - {eventData.recommendation?.roi || 0}% + {eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}% @@ -270,7 +270,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 이벤트 제목 - {eventData.recommendation?.title} + {eventData.recommendation?.recommendation.title}
@@ -288,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 경품 - {eventData.recommendation?.prize} + {eventData.recommendation?.recommendation.mechanics.details || ''}
@@ -306,7 +306,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS 참여 방법 - {eventData.recommendation?.participationMethod} + {eventData.recommendation?.recommendation.mechanics.details || ''}
diff --git a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx index a0fc4f7..ddead7a 100644 --- a/src/app/(main)/events/create/steps/ContentPreviewStep.tsx +++ b/src/app/(main)/events/create/steps/ContentPreviewStep.tsx @@ -67,13 +67,16 @@ const imageStyles: ImageStyle[] = [ ]; interface ContentPreviewStepProps { + eventId?: string; + eventTitle?: string; + eventDescription?: string; onNext: (imageStyle: string, images: ImageInfo[]) => void; onSkip: () => void; onBack: () => void; } interface EventCreationData { - eventDraftId: string; // Changed from number to string + eventDraftId: string; eventTitle: string; eventDescription: string; industry: string; @@ -83,6 +86,9 @@ interface EventCreationData { } export default function ContentPreviewStep({ + eventId: propsEventId, + eventTitle: propsEventTitle, + eventDescription: propsEventDescription, onNext, onSkip, onBack, @@ -112,25 +118,35 @@ export default function ContentPreviewStep({ handleGenerateImagesAuto(data); } }); - } else { - // Mock 데이터가 없으면 자동으로 설정 - const mockData: EventCreationData = { - eventDraftId: "1761634317010", // Changed to string - eventTitle: "맥주 파티 이벤트", - eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", - industry: "음식점", - location: "강남", - trends: ["파티", "맥주", "생맥주"], - prize: "생맥주 1잔" + } else if (propsEventId) { + // Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만) + console.log('✅ Using event data from props:', propsEventId); + const data: EventCreationData = { + eventDraftId: propsEventId, + eventTitle: propsEventTitle || '', + eventDescription: propsEventDescription || '', + industry: '', + location: '', + trends: [], + prize: '', }; + setEventData(data); - console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.'); - localStorage.setItem('eventCreationData', JSON.stringify(mockData)); - setEventData(mockData); - loadImages(mockData); + // 이미지 조회 시도 + loadImages(data).then((hasImages) => { + if (!hasImages) { + console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...'); + handleGenerateImagesAuto(data); + } + }); + } else { + // 이벤트 데이터가 없으면 에러 표시 + console.error('❌ No event data available. Cannot proceed.'); + setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.'); + setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [propsEventId, propsEventTitle, propsEventDescription]); const loadImages = async (data: EventCreationData): Promise => { try { diff --git a/src/app/(main)/events/create/steps/RecommendationStep.tsx b/src/app/(main)/events/create/steps/RecommendationStep.tsx index a201950..e3472b8 100644 --- a/src/app/(main)/events/create/steps/RecommendationStep.tsx +++ b/src/app/(main)/events/create/steps/RecommendationStep.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +'use client'; + +import { useState, useEffect } from 'react'; import { Box, Container, @@ -13,11 +15,12 @@ import { RadioGroup, FormControlLabel, IconButton, - Tabs, - Tab, + CircularProgress, + Alert, } from '@mui/material'; import { ArrowBack, Edit, Insights } from '@mui/icons-material'; import { EventObjective, BudgetLevel, EventMethod } from '../page'; +import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api'; // 디자인 시스템 색상 const colors = { @@ -37,130 +40,288 @@ const colors = { }, }; -interface Recommendation { - id: string; - budget: BudgetLevel; - method: EventMethod; - title: string; - prize: string; - participationMethod: string; - expectedParticipants: number; - estimatedCost: number; - roi: number; -} - -// Mock 추천 데이터 -const mockRecommendations: Recommendation[] = [ - // 저비용 - { - id: 'low-online', - budget: 'low', - method: 'online', - title: 'SNS 팔로우 이벤트', - prize: '커피 쿠폰', - participationMethod: 'SNS 팔로우', - expectedParticipants: 180, - estimatedCost: 250000, - roi: 520, - }, - { - id: 'low-offline', - budget: 'low', - method: 'offline', - title: '전화번호 등록 이벤트', - prize: '커피 쿠폰', - participationMethod: '방문 시 전화번호 등록', - expectedParticipants: 120, - estimatedCost: 300000, - roi: 380, - }, - // 중비용 - { - id: 'medium-online', - budget: 'medium', - method: 'online', - title: '리뷰 작성 이벤트', - prize: '상품권 5만원', - participationMethod: '네이버 리뷰 작성', - expectedParticipants: 250, - estimatedCost: 800000, - roi: 450, - }, - { - id: 'medium-offline', - budget: 'medium', - method: 'offline', - title: '스탬프 적립 이벤트', - prize: '상품권 5만원', - participationMethod: '3회 방문 시 스탬프', - expectedParticipants: 200, - estimatedCost: 1000000, - roi: 380, - }, - // 고비용 - { - id: 'high-online', - budget: 'high', - method: 'online', - title: '인플루언서 협업 이벤트', - prize: '애플 에어팟', - participationMethod: '게시물 공유 및 댓글', - expectedParticipants: 500, - estimatedCost: 2000000, - roi: 380, - }, - { - id: 'high-offline', - budget: 'high', - method: 'offline', - title: 'VIP 고객 초대 이벤트', - prize: '애플 에어팟', - participationMethod: '누적 10회 방문', - expectedParticipants: 300, - estimatedCost: 2500000, - roi: 320, - }, -]; - interface RecommendationStepProps { objective?: EventObjective; - onNext: (data: Recommendation) => void; + eventId?: string; // 이전 단계에서 생성된 eventId + onNext: (data: { + recommendation: EventRecommendation; + eventId: string; + }) => void; onBack: () => void; } -export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) { - const [selectedBudget, setSelectedBudget] = useState('low'); - const [selected, setSelected] = useState(null); - const [editedData, setEditedData] = useState>({}); +export default function RecommendationStep({ + objective, + eventId: initialEventId, + onNext, + onBack +}: RecommendationStepProps) { + const [eventId, setEventId] = useState(initialEventId || null); + const [jobId, setJobId] = useState(null); + const [loading, setLoading] = useState(false); + const [polling, setPolling] = useState(false); + const [error, setError] = useState(null); - const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget); + const [aiResult, setAiResult] = useState(null); + const [selected, setSelected] = useState(null); + const [editedData, setEditedData] = useState>({}); - const handleNext = () => { - const selectedRec = mockRecommendations.find((r) => r.id === selected); - if (selectedRec && selected) { - const edited = editedData[selected]; - onNext({ - ...selectedRec, - title: edited?.title || selectedRec.title, - prize: edited?.prize || selectedRec.prize, - }); + // 컴포넌트 마운트 시 AI 추천 요청 + useEffect(() => { + if (!eventId && objective) { + // Step 1: 이벤트 생성 + createEventAndRequestAI(); + } else if (eventId) { + // 이미 eventId가 있으면 AI 추천 요청 + requestAIRecommendations(eventId); + } + }, []); + + const createEventAndRequestAI = async () => { + try { + setLoading(true); + setError(null); + + // Step 1: 이벤트 목적 선택 및 생성 + const eventResponse = await eventApi.selectObjective(objective || '신규 고객 유치'); + const newEventId = eventResponse.eventId; + setEventId(newEventId); + + // Step 2: AI 추천 요청 + await requestAIRecommendations(newEventId); + } catch (err: any) { + console.error('이벤트 생성 실패:', err); + setError(err.response?.data?.message || '이벤트 생성에 실패했습니다'); + setLoading(false); } }; - const handleEditTitle = (id: string, title: string) => { + const requestAIRecommendations = async (evtId: string) => { + try { + setLoading(true); + setError(null); + + // 사용자 정보에서 매장 정보 가져오기 + const userProfile = JSON.parse(localStorage.getItem('userProfile') || '{}'); + const storeInfo = { + storeId: userProfile.storeId || '1', + storeName: userProfile.storeName || '내 매장', + category: userProfile.industry || '음식점', + description: userProfile.businessHours || '', + }; + + // AI 추천 요청 + const jobResponse = await eventApi.requestAiRecommendations(evtId, storeInfo); + setJobId(jobResponse.jobId); + + // Job 폴링 시작 + pollJobStatus(jobResponse.jobId, evtId); + } catch (err: any) { + console.error('AI 추천 요청 실패:', err); + setError(err.response?.data?.message || 'AI 추천 요청에 실패했습니다'); + setLoading(false); + } + }; + + const pollJobStatus = async (jId: string, evtId: string) => { + setPolling(true); + const maxAttempts = 60; // 최대 5분 (5초 간격) + let attempts = 0; + + const poll = async () => { + try { + const status = await eventApi.getJobStatus(jId); + console.log('Job 상태:', status); + + if (status.status === 'COMPLETED') { + // AI 추천 결과 조회 + const recommendations = await aiApi.getRecommendations(evtId); + setAiResult(recommendations); + setLoading(false); + setPolling(false); + return; + } else if (status.status === 'FAILED') { + setError(status.errorMessage || 'AI 추천 생성에 실패했습니다'); + setLoading(false); + setPolling(false); + return; + } + + // 계속 폴링 + attempts++; + if (attempts < maxAttempts) { + setTimeout(poll, 5000); // 5초 후 재시도 + } else { + setError('AI 추천 생성 시간이 초과되었습니다'); + setLoading(false); + setPolling(false); + } + } catch (err: any) { + console.error('Job 상태 조회 실패:', err); + setError(err.response?.data?.message || 'Job 상태 조회에 실패했습니다'); + setLoading(false); + setPolling(false); + } + }; + + poll(); + }; + + const handleNext = async () => { + if (selected === null || !aiResult || !eventId) return; + + const selectedRec = aiResult.recommendations[selected - 1]; + const edited = editedData[selected]; + + try { + setLoading(true); + + // AI 추천 선택 API 호출 + await eventApi.selectRecommendation(eventId, { + recommendationId: `${eventId}-opt${selected}`, + customizations: { + eventName: edited?.title || selectedRec.title, + description: edited?.description || selectedRec.description, + }, + }); + + // 다음 단계로 이동 + onNext({ + recommendation: { + ...selectedRec, + title: edited?.title || selectedRec.title, + description: edited?.description || selectedRec.description, + }, + eventId, + }); + } catch (err: any) { + console.error('추천 선택 실패:', err); + setError(err.response?.data?.message || '추천 선택에 실패했습니다'); + } finally { + setLoading(false); + } + }; + + const handleEditTitle = (optionNumber: number, title: string) => { setEditedData((prev) => ({ ...prev, - [id]: { ...prev[id], title }, + [optionNumber]: { + ...prev[optionNumber], + title + }, })); }; - const handleEditPrize = (id: string, prize: string) => { + const handleEditDescription = (optionNumber: number, description: string) => { setEditedData((prev) => ({ ...prev, - [id]: { ...prev[id], prize }, + [optionNumber]: { + ...prev[optionNumber], + description + }, })); }; + // 로딩 상태 표시 + if (loading || polling) { + return ( + + + + + + + + AI 이벤트 추천 + + + + + + + AI가 최적의 이벤트를 생성하고 있습니다... + + + 업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다 + + + + + ); + } + + // 에러 상태 표시 + if (error) { + return ( + + + + + + + + AI 이벤트 추천 + + + + + {error} + + + + + + + + + ); + } + + // AI 결과가 없으면 로딩 표시 + if (!aiResult) { + return ( + + + + + + ); + } + return ( @@ -195,158 +356,159 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte 📍 업종 트렌드 - - 음식점업 신년 프로모션 트렌드 - + {aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))}
🗺️ 지역 트렌드 - - 강남구 음식점 할인 이벤트 증가 - + {aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))} ☀️ 시즌 트렌드 - - 설 연휴 특수 대비 고객 유치 전략 - + {aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => ( + + • {trend.description} + + ))}
- {/* Budget Selection */} + {/* AI Recommendations */} - 예산별 추천 이벤트 + AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션) - 각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다 + 각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다. - setSelectedBudget(value)} - variant="fullWidth" - sx={{ mb: 8 }} - > - - - - {/* Recommendations */} - setSelected(e.target.value)}> + setSelected(Number(e.target.value))}> - {budgetRecommendations.map((rec) => ( - + {aiResult.recommendations.map((rec) => ( + setSelected(rec.id)} + onClick={() => setSelected(rec.optionNumber)} > - + + + + } + label="" + sx={{ m: 0 }} /> - } label="" sx={{ m: 0 }} /> handleEditTitle(rec.id, e.target.value)} + value={editedData[rec.optionNumber]?.title || rec.title} + onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)} onClick={(e) => e.stopPropagation()} sx={{ mb: 4 }} InputProps={{ endAdornment: , - sx: { fontSize: '1rem', py: 2 }, + sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 }, }} /> - - - 경품 - - handleEditPrize(rec.id, e.target.value)} - onClick={(e) => e.stopPropagation()} - InputProps={{ - endAdornment: , - sx: { fontSize: '1rem' }, - }} - /> - + handleEditDescription(rec.optionNumber, e.target.value)} + onClick={(e) => e.stopPropagation()} + sx={{ mb: 4 }} + InputProps={{ + sx: { fontSize: '1rem' }, + }} + /> - - + + - 참여 방법 + 타겟 고객 - {rec.participationMethod} + {rec.targetAudience} - - - 예상 참여 - - - {rec.expectedParticipants}명 - - - + 예상 비용 - {(rec.estimatedCost / 10000).toFixed(0)}만원 + {(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원 - + - 투자대비수익률 + 예상 신규 고객 + + + {rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명 + + + + + ROI - {rec.roi}% + {rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}% + + + + + 차별점 + + + {rec.differentiator} @@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte fullWidth variant="contained" size="large" - disabled={!selected} + disabled={selected === null || loading} onClick={handleNext} sx={{ py: 3, @@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte }, }} > - 다음 + {loading ? : '다음'} diff --git a/src/shared/api/aiApi.ts b/src/shared/api/aiApi.ts new file mode 100644 index 0000000..c541eb8 --- /dev/null +++ b/src/shared/api/aiApi.ts @@ -0,0 +1,178 @@ +import axios, { AxiosInstance } from 'axios'; + +// AI Service API 클라이언트 +const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083'; + +export const aiApiClient: AxiosInstance = axios.create({ + baseURL: AI_API_BASE_URL, + timeout: 300000, // AI 생성은 최대 5분 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor +aiApiClient.interceptors.request.use( + (config) => { + console.log('🤖 AI API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('❌ AI API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor +aiApiClient.interceptors.response.use( + (response) => { + console.log('✅ AI API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ AI API Error:', { + message: error.message, + status: error.response?.status, + url: error.config?.url, + data: error.response?.data, + }); + return Promise.reject(error); + } +); + +// Types +export interface TrendKeyword { + keyword: string; + relevance: number; + description: string; +} + +export interface TrendAnalysis { + industryTrends: TrendKeyword[]; + regionalTrends: TrendKeyword[]; + seasonalTrends: TrendKeyword[]; +} + +export interface ExpectedMetrics { + newCustomers: { + min: number; + max: number; + }; + repeatVisits?: { + min: number; + max: number; + }; + revenueIncrease: { + min: number; + max: number; + }; + roi: { + min: number; + max: number; + }; + socialEngagement?: { + estimatedPosts: number; + estimatedReach: number; + }; +} + +export interface EventRecommendation { + optionNumber: number; + concept: string; + title: string; + description: string; + targetAudience: string; + duration: { + recommendedDays: number; + recommendedPeriod?: string; + }; + mechanics: { + type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO'; + details: string; + }; + promotionChannels: string[]; + estimatedCost: { + min: number; + max: number; + breakdown?: { + material?: number; + promotion?: number; + discount?: number; + }; + }; + expectedMetrics: ExpectedMetrics; + differentiator: string; +} + +export interface AIRecommendationResult { + eventId: string; + trendAnalysis: TrendAnalysis; + recommendations: EventRecommendation[]; + generatedAt: string; + expiresAt: string; + aiProvider: 'CLAUDE' | 'GPT4'; +} + +export interface JobStatusResponse { + jobId: string; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + message: string; + eventId?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + failedAt?: string; + errorMessage?: string; + retryCount?: number; + processingTimeMs?: number; +} + +export interface HealthCheckResponse { + status: 'UP' | 'DOWN' | 'DEGRADED'; + timestamp: string; + services: { + kafka: 'UP' | 'DOWN'; + redis: 'UP' | 'DOWN'; + claude_api: 'UP' | 'DOWN' | 'CIRCUIT_OPEN'; + gpt4_api?: 'UP' | 'DOWN' | 'CIRCUIT_OPEN'; + circuit_breaker: 'CLOSED' | 'OPEN' | 'HALF_OPEN'; + }; +} + +// API Functions +export const aiApi = { + // 헬스체크 + healthCheck: async (): Promise => { + const response = await aiApiClient.get('/health'); + return response.data; + }, + + // Job 상태 조회 (Internal API) + getJobStatus: async (jobId: string): Promise => { + const response = await aiApiClient.get(`/internal/jobs/${jobId}/status`); + return response.data; + }, + + // AI 추천 결과 조회 (Internal API) + getRecommendations: async (eventId: string): Promise => { + const response = await aiApiClient.get(`/internal/recommendations/${eventId}`); + return response.data; + }, +}; + +export default aiApi; diff --git a/src/shared/api/eventApi.ts b/src/shared/api/eventApi.ts new file mode 100644 index 0000000..4e9465f --- /dev/null +++ b/src/shared/api/eventApi.ts @@ -0,0 +1,329 @@ +import axios, { AxiosInstance } from 'axios'; + +// Event Service API 클라이언트 +const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080'; +const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api'; + +export const eventApiClient: AxiosInstance = axios.create({ + baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`, + timeout: 30000, // Job 폴링 고려 + headers: { + 'Content-Type': 'application/json', + }, +}); + +// Request interceptor +eventApiClient.interceptors.request.use( + (config) => { + console.log('📅 Event API Request:', { + method: config.method?.toUpperCase(), + url: config.url, + baseURL: config.baseURL, + data: config.data, + }); + + const token = localStorage.getItem('accessToken'); + if (token && config.headers) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + console.error('❌ Event API Request Error:', error); + return Promise.reject(error); + } +); + +// Response interceptor +eventApiClient.interceptors.response.use( + (response) => { + console.log('✅ Event API Response:', { + status: response.status, + url: response.config.url, + data: response.data, + }); + return response; + }, + (error) => { + console.error('❌ Event API Error:', { + message: error.message, + status: error.response?.status, + url: error.config?.url, + data: error.response?.data, + }); + return Promise.reject(error); + } +); + +// Types +export interface EventObjectiveRequest { + objective: string; // "신규 고객 유치", "재방문 유도", "매출 증대", "브랜드 인지도 향상" +} + +export interface EventCreatedResponse { + eventId: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + objective: string; + createdAt: string; +} + +export interface AiRecommendationRequest { + storeInfo: { + storeId: string; + storeName: string; + category: string; + description?: string; + }; +} + +export interface JobAcceptedResponse { + jobId: string; + status: 'PENDING'; + message: string; +} + +export interface EventJobStatusResponse { + jobId: string; + jobType: 'AI_RECOMMENDATION' | 'IMAGE_GENERATION'; + status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; + progress: number; + resultKey?: string; + errorMessage?: string; + createdAt: string; + completedAt?: string; +} + +export interface SelectRecommendationRequest { + recommendationId: string; + customizations?: { + eventName?: string; + description?: string; + startDate?: string; + endDate?: string; + discountRate?: number; + }; +} + +export interface ImageGenerationRequest { + eventInfo: { + eventName: string; + description: string; + promotionType: string; + }; + imageCount?: number; +} + +export interface SelectChannelsRequest { + channels: ('WEBSITE' | 'KAKAO' | 'INSTAGRAM' | 'FACEBOOK' | 'NAVER_BLOG')[]; +} + +export interface ChannelDistributionResult { + channel: string; + success: boolean; + url?: string; + message: string; +} + +export interface EventPublishedResponse { + eventId: string; + status: 'PUBLISHED'; + publishedAt: string; + channels: string[]; + distributionResults: ChannelDistributionResult[]; +} + +export interface EventSummary { + eventId: string; + eventName: string; + objective: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + startDate: string; + endDate: string; + thumbnailUrl?: string; + createdAt: string; +} + +export interface PageInfo { + page: number; + size: number; + totalElements: number; + totalPages: number; +} + +export interface EventListResponse { + content: EventSummary[]; + page: PageInfo; +} + +export interface GeneratedImage { + imageId: string; + imageUrl: string; + isSelected: boolean; + createdAt: string; +} + +export interface AiRecommendation { + recommendationId: string; + eventName: string; + description: string; + promotionType: string; + targetAudience: string; + isSelected: boolean; +} + +export interface EventDetailResponse { + eventId: string; + userId: string; + storeId: string; + eventName: string; + objective: string; + description: string; + targetAudience: string; + promotionType: string; + discountRate?: number; + startDate: string; + endDate: string; + status: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + selectedImageId?: string; + selectedImageUrl?: string; + generatedImages?: GeneratedImage[]; + channels?: string[]; + aiRecommendations?: AiRecommendation[]; + createdAt: string; + updatedAt: string; +} + +export interface UpdateEventRequest { + eventName?: string; + description?: string; + startDate?: string; + endDate?: string; + discountRate?: number; +} + +export interface EndEventRequest { + reason: string; +} + +// API Functions +export const eventApi = { + // Step 1: 목적 선택 및 이벤트 생성 + selectObjective: async (objective: string): Promise => { + const response = await eventApiClient.post('/events/objectives', { + objective, + }); + return response.data; + }, + + // Step 2: AI 추천 요청 + requestAiRecommendations: async ( + eventId: string, + storeInfo: AiRecommendationRequest['storeInfo'] + ): Promise => { + const response = await eventApiClient.post( + `/events/${eventId}/ai-recommendations`, + { storeInfo } + ); + return response.data; + }, + + // Job 상태 폴링 + getJobStatus: async (jobId: string): Promise => { + const response = await eventApiClient.get(`/jobs/${jobId}`); + return response.data; + }, + + // AI 추천 선택 + selectRecommendation: async ( + eventId: string, + request: SelectRecommendationRequest + ): Promise => { + const response = await eventApiClient.put( + `/events/${eventId}/recommendations`, + request + ); + return response.data; + }, + + // Step 3: 이미지 생성 요청 + requestImageGeneration: async ( + eventId: string, + request: ImageGenerationRequest + ): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/images`, request); + return response.data; + }, + + // 이미지 선택 + selectImage: async (eventId: string, imageId: string): Promise => { + const response = await eventApiClient.put( + `/events/${eventId}/images/${imageId}/select` + ); + return response.data; + }, + + // Step 4: 이미지 편집 + editImage: async ( + eventId: string, + imageId: string, + editRequest: any + ): Promise<{ imageId: string; imageUrl: string; editedAt: string }> => { + const response = await eventApiClient.put(`/events/${eventId}/images/${imageId}/edit`, editRequest); + return response.data; + }, + + // Step 5: 배포 채널 선택 + selectChannels: async (eventId: string, channels: string[]): Promise => { + const response = await eventApiClient.put(`/events/${eventId}/channels`, { + channels, + }); + return response.data; + }, + + // Step 6: 최종 배포 + publishEvent: async (eventId: string): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/publish`); + return response.data; + }, + + // 이벤트 목록 조회 + getEvents: async (params?: { + status?: 'DRAFT' | 'PUBLISHED' | 'ENDED'; + objective?: string; + search?: string; + page?: number; + size?: number; + sort?: string; + order?: 'asc' | 'desc'; + }): Promise => { + const response = await eventApiClient.get('/events', { params }); + return response.data; + }, + + // 이벤트 상세 조회 + getEventDetail: async (eventId: string): Promise => { + const response = await eventApiClient.get(`/events/${eventId}`); + return response.data; + }, + + // 이벤트 수정 + updateEvent: async (eventId: string, request: UpdateEventRequest): Promise => { + const response = await eventApiClient.put(`/events/${eventId}`, request); + return response.data; + }, + + // 이벤트 삭제 + deleteEvent: async (eventId: string): Promise => { + await eventApiClient.delete(`/events/${eventId}`); + }, + + // 이벤트 조기 종료 + endEvent: async (eventId: string, reason: string): Promise => { + const response = await eventApiClient.post(`/events/${eventId}/end`, { + reason, + }); + return response.data; + }, +}; + +export default eventApi; diff --git a/src/shared/api/index.ts b/src/shared/api/index.ts index 51e397f..2afee3f 100644 --- a/src/shared/api/index.ts +++ b/src/shared/api/index.ts @@ -1,2 +1,6 @@ -export { apiClient } from './client'; +export { apiClient, participationClient } from './client'; export type { ApiError } from './types'; +export * from './contentApi'; +export * from './aiApi'; +export * from './eventApi'; +export * from './participation.api';