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';