mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 06:16:24 +00:00
백엔드 AI 서비스와 프론트엔드 완전 연동
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
0c14cfe289
commit
c9614263c0
@ -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 }) => (
|
||||
<ContentPreviewStep
|
||||
eventId={context.recommendation?.eventId}
|
||||
eventTitle={context.recommendation?.recommendation.title}
|
||||
eventDescription={context.recommendation?.recommendation.description}
|
||||
onNext={(imageStyle, images) => {
|
||||
history.push('contentEdit', {
|
||||
...context,
|
||||
@ -134,8 +163,8 @@ export default function EventCreatePage() {
|
||||
)}
|
||||
contentEdit={({ context, history }) => (
|
||||
<ContentEditStep
|
||||
initialTitle={context.recommendation?.title || ''}
|
||||
initialPrize={context.recommendation?.prize || ''}
|
||||
initialTitle={context.recommendation?.recommendation.title || ''}
|
||||
initialPrize={''}
|
||||
onNext={(contentEdit) => {
|
||||
history.push('approval', { ...context, contentEdit });
|
||||
}}
|
||||
|
||||
@ -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 || '이벤트 제목'}
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -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}
|
||||
<Typography component="span" sx={{
|
||||
fontSize: '1rem',
|
||||
ml: 0.5,
|
||||
@ -204,7 +204,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{((eventData.recommendation?.estimatedCost || 0) / 10000).toFixed(0)}
|
||||
{((eventData.recommendation?.recommendation.estimatedCost.max || 0) / 10000).toFixed(0)}
|
||||
<Typography component="span" sx={{
|
||||
fontSize: '1rem',
|
||||
ml: 0.5,
|
||||
@ -250,7 +250,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{eventData.recommendation?.roi || 0}%
|
||||
{eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}%
|
||||
</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@ -270,7 +270,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
이벤트 제목
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.title}
|
||||
{eventData.recommendation?.recommendation.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
@ -288,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
경품
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.prize}
|
||||
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
<IconButton size="small">
|
||||
@ -306,7 +306,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
|
||||
참여 방법
|
||||
</Typography>
|
||||
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
|
||||
{eventData.recommendation?.participationMethod}
|
||||
{eventData.recommendation?.recommendation.mechanics.details || ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@ -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<boolean> => {
|
||||
try {
|
||||
|
||||
@ -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<BudgetLevel>('low');
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({});
|
||||
export default function RecommendationStep({
|
||||
objective,
|
||||
eventId: initialEventId,
|
||||
onNext,
|
||||
onBack
|
||||
}: RecommendationStepProps) {
|
||||
const [eventId, setEventId] = useState<string | null>(initialEventId || null);
|
||||
const [jobId, setJobId] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const budgetRecommendations = mockRecommendations.filter((r) => r.budget === selectedBudget);
|
||||
const [aiResult, setAiResult] = useState<AIRecommendationResult | null>(null);
|
||||
const [selected, setSelected] = useState<number | null>(null);
|
||||
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
|
||||
|
||||
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 (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||
AI 이벤트 추천
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4, py: 12 }}>
|
||||
<CircularProgress size={60} sx={{ color: colors.purple }} />
|
||||
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '1.25rem' }}>
|
||||
AI가 최적의 이벤트를 생성하고 있습니다...
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
업종, 지역, 시즌 트렌드를 분석하여 맞춤형 이벤트를 추천합니다
|
||||
</Typography>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태 표시
|
||||
if (error) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
|
||||
<IconButton onClick={onBack} sx={{ width: 48, height: 48 }}>
|
||||
<ArrowBack />
|
||||
</IconButton>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
|
||||
AI 이벤트 추천
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Alert severity="error" sx={{ mb: 4 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 4 }}>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
size="large"
|
||||
onClick={onBack}
|
||||
sx={{
|
||||
py: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
이전
|
||||
</Button>
|
||||
<Button
|
||||
fullWidth
|
||||
variant="contained"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
if (eventId) {
|
||||
requestAIRecommendations(eventId);
|
||||
} else {
|
||||
createEventAndRequestAI();
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
py: 3,
|
||||
borderRadius: 3,
|
||||
fontSize: '1rem',
|
||||
fontWeight: 700,
|
||||
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
|
||||
}}
|
||||
>
|
||||
다시 시도
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// AI 결과가 없으면 로딩 표시
|
||||
if (!aiResult) {
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
<CircularProgress />
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
|
||||
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
|
||||
@ -195,158 +356,159 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
📍 업종 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
음식점업 신년 프로모션 트렌드
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
🗺️ 지역 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
강남구 음식점 할인 이벤트 증가
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
|
||||
☀️ 시즌 트렌드
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
|
||||
설 연휴 특수 대비 고객 유치 전략
|
||||
</Typography>
|
||||
{aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
|
||||
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
|
||||
• {trend.description}
|
||||
</Typography>
|
||||
))}
|
||||
</Grid>
|
||||
</Grid>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Budget Selection */}
|
||||
{/* AI Recommendations */}
|
||||
<Box sx={{ mb: 8 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
|
||||
예산별 추천 이벤트
|
||||
AI 추천 이벤트 ({aiResult.recommendations.length}가지 옵션)
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
|
||||
각 예산별 2가지 방식 (온라인 1개, 오프라인 1개)을 추천합니다
|
||||
각 옵션은 차별화된 컨셉으로 구성되어 있습니다. 원하시는 옵션을 선택하고 수정할 수 있습니다.
|
||||
</Typography>
|
||||
<Tabs
|
||||
value={selectedBudget}
|
||||
onChange={(_, value) => setSelectedBudget(value)}
|
||||
variant="fullWidth"
|
||||
sx={{ mb: 8 }}
|
||||
>
|
||||
<Tab
|
||||
label="💰 저비용"
|
||||
value="low"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
<Tab
|
||||
label="💰💰 중비용"
|
||||
value="medium"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
<Tab
|
||||
label="💰💰💰 고비용"
|
||||
value="high"
|
||||
sx={{ py: 3, fontSize: '1rem', fontWeight: 600 }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* Recommendations */}
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(e.target.value)}>
|
||||
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
|
||||
<Grid container spacing={6} sx={{ mb: 10 }}>
|
||||
{budgetRecommendations.map((rec) => (
|
||||
<Grid item xs={12} md={6} key={rec.id}>
|
||||
{aiResult.recommendations.map((rec) => (
|
||||
<Grid item xs={12} key={rec.optionNumber}>
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: 4,
|
||||
border: selected === rec.id ? 2 : 1,
|
||||
borderColor: selected === rec.id ? colors.purple : 'divider',
|
||||
bgcolor: selected === rec.id ? `${colors.purpleLight}40` : 'background.paper',
|
||||
border: selected === rec.optionNumber ? 2 : 1,
|
||||
borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
|
||||
bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
|
||||
transition: 'all 0.2s',
|
||||
boxShadow: selected === rec.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
boxShadow: selected === rec.optionNumber ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
'&:hover': {
|
||||
borderColor: colors.purple,
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
}}
|
||||
onClick={() => setSelected(rec.id)}
|
||||
onClick={() => setSelected(rec.optionNumber)}
|
||||
>
|
||||
<CardContent sx={{ p: 6 }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
|
||||
<Chip
|
||||
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
|
||||
color={rec.method === 'online' ? 'primary' : 'secondary'}
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||
<Chip
|
||||
label={`옵션 ${rec.optionNumber}`}
|
||||
color="primary"
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
/>
|
||||
<Chip
|
||||
label={rec.concept}
|
||||
variant="outlined"
|
||||
size="medium"
|
||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||
/>
|
||||
</Box>
|
||||
<FormControlLabel
|
||||
value={rec.optionNumber}
|
||||
control={<Radio />}
|
||||
label=""
|
||||
sx={{ m: 0 }}
|
||||
/>
|
||||
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
|
||||
</Box>
|
||||
|
||||
<TextField
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.title || rec.title}
|
||||
onChange={(e) => 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: <Edit fontSize="small" color="action" />,
|
||||
sx: { fontSize: '1rem', py: 2 },
|
||||
sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Box sx={{ mb: 4 }}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}>
|
||||
경품
|
||||
</Typography>
|
||||
<TextField
|
||||
fullWidth
|
||||
size="medium"
|
||||
variant="outlined"
|
||||
value={editedData[rec.id]?.prize || rec.prize}
|
||||
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
InputProps={{
|
||||
endAdornment: <Edit fontSize="small" color="action" />,
|
||||
sx: { fontSize: '1rem' },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
rows={2}
|
||||
variant="outlined"
|
||||
value={editedData[rec.optionNumber]?.description || rec.description}
|
||||
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
sx={{ mb: 4 }}
|
||||
InputProps={{
|
||||
sx: { fontSize: '1rem' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<Grid container spacing={4} sx={{ mt: 4 }}>
|
||||
<Grid item xs={6}>
|
||||
<Grid container spacing={4} sx={{ mt: 2 }}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
참여 방법
|
||||
타겟 고객
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.participationMethod}
|
||||
{rec.targetAudience}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
예상 참여
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.expectedParticipants}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
예상 비용
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{(rec.estimatedCost / 10000).toFixed(0)}만원
|
||||
{(rec.estimatedCost.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}만원
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
투자대비수익률
|
||||
예상 신규 고객
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
|
||||
{rec.expectedMetrics.newCustomers.min}~{rec.expectedMetrics.newCustomers.max}명
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={6} md={3}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
ROI
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}>
|
||||
{rec.roi}%
|
||||
{rec.expectedMetrics.roi.min}~{rec.expectedMetrics.roi.max}%
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
|
||||
차별점
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontSize: '0.95rem', mt: 1 }}>
|
||||
{rec.differentiator}
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
@ -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 ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
|
||||
</Button>
|
||||
</Box>
|
||||
</Container>
|
||||
|
||||
178
src/shared/api/aiApi.ts
Normal file
178
src/shared/api/aiApi.ts
Normal file
@ -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<HealthCheckResponse> => {
|
||||
const response = await aiApiClient.get<HealthCheckResponse>('/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Job 상태 조회 (Internal API)
|
||||
getJobStatus: async (jobId: string): Promise<JobStatusResponse> => {
|
||||
const response = await aiApiClient.get<JobStatusResponse>(`/internal/jobs/${jobId}/status`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// AI 추천 결과 조회 (Internal API)
|
||||
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
|
||||
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default aiApi;
|
||||
329
src/shared/api/eventApi.ts
Normal file
329
src/shared/api/eventApi.ts
Normal file
@ -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<EventCreatedResponse> => {
|
||||
const response = await eventApiClient.post<EventCreatedResponse>('/events/objectives', {
|
||||
objective,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 2: AI 추천 요청
|
||||
requestAiRecommendations: async (
|
||||
eventId: string,
|
||||
storeInfo: AiRecommendationRequest['storeInfo']
|
||||
): Promise<JobAcceptedResponse> => {
|
||||
const response = await eventApiClient.post<JobAcceptedResponse>(
|
||||
`/events/${eventId}/ai-recommendations`,
|
||||
{ storeInfo }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Job 상태 폴링
|
||||
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
|
||||
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// AI 추천 선택
|
||||
selectRecommendation: async (
|
||||
eventId: string,
|
||||
request: SelectRecommendationRequest
|
||||
): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(
|
||||
`/events/${eventId}/recommendations`,
|
||||
request
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 3: 이미지 생성 요청
|
||||
requestImageGeneration: async (
|
||||
eventId: string,
|
||||
request: ImageGenerationRequest
|
||||
): Promise<JobAcceptedResponse> => {
|
||||
const response = await eventApiClient.post<JobAcceptedResponse>(`/events/${eventId}/images`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이미지 선택
|
||||
selectImage: async (eventId: string, imageId: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(
|
||||
`/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<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}/channels`, {
|
||||
channels,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Step 6: 최종 배포
|
||||
publishEvent: async (eventId: string): Promise<EventPublishedResponse> => {
|
||||
const response = await eventApiClient.post<EventPublishedResponse>(`/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<EventListResponse> => {
|
||||
const response = await eventApiClient.get<EventListResponse>('/events', { params });
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 상세 조회
|
||||
getEventDetail: async (eventId: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.get<EventDetailResponse>(`/events/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 수정
|
||||
updateEvent: async (eventId: string, request: UpdateEventRequest): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}`, request);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// 이벤트 삭제
|
||||
deleteEvent: async (eventId: string): Promise<void> => {
|
||||
await eventApiClient.delete(`/events/${eventId}`);
|
||||
},
|
||||
|
||||
// 이벤트 조기 종료
|
||||
endEvent: async (eventId: string, reason: string): Promise<EventDetailResponse> => {
|
||||
const response = await eventApiClient.post<EventDetailResponse>(`/events/${eventId}/end`, {
|
||||
reason,
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default eventApi;
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user