백엔드 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:
박세원 2025-10-29 13:49:45 +09:00
parent 0c14cfe289
commit c9614263c0
7 changed files with 956 additions and 238 deletions

View File

@ -18,17 +18,43 @@ export interface EventData {
eventDraftId?: number; eventDraftId?: number;
objective?: EventObjective; objective?: EventObjective;
recommendation?: { recommendation?: {
budget: BudgetLevel; recommendation: {
method: EventMethod; optionNumber: number;
title: string; concept: string;
prize: string; title: string;
description?: string; description: string;
industry?: string; targetAudience: string;
location?: string; duration: {
participationMethod: string; recommendedDays: number;
expectedParticipants: number; recommendedPeriod?: string;
estimatedCost: number; };
roi: number; 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?: { contentPreview?: {
imageStyle: string; imageStyle: string;
@ -96,13 +122,13 @@ export default function EventCreatePage() {
if (needsContent) { if (needsContent) {
// localStorage에 이벤트 정보 저장 // localStorage에 이벤트 정보 저장
const eventData = { const eventData = {
eventDraftId: context.eventDraftId || Date.now(), // 임시 ID 생성 eventDraftId: context.recommendation?.eventId || String(Date.now()), // eventId 사용
eventTitle: context.recommendation?.title || '', eventTitle: context.recommendation?.recommendation.title || '',
eventDescription: context.recommendation?.description || context.recommendation?.participationMethod || '', eventDescription: context.recommendation?.recommendation.description || '',
industry: context.recommendation?.industry || '', industry: '',
location: context.recommendation?.location || '', location: '',
trends: [], // 필요시 context에서 추가 trends: context.recommendation?.recommendation.promotionChannels || [],
prize: context.recommendation?.prize || '', prize: '',
}; };
localStorage.setItem('eventCreationData', JSON.stringify(eventData)); localStorage.setItem('eventCreationData', JSON.stringify(eventData));
@ -118,6 +144,9 @@ export default function EventCreatePage() {
)} )}
contentPreview={({ context, history }) => ( contentPreview={({ context, history }) => (
<ContentPreviewStep <ContentPreviewStep
eventId={context.recommendation?.eventId}
eventTitle={context.recommendation?.recommendation.title}
eventDescription={context.recommendation?.recommendation.description}
onNext={(imageStyle, images) => { onNext={(imageStyle, images) => {
history.push('contentEdit', { history.push('contentEdit', {
...context, ...context,
@ -134,8 +163,8 @@ export default function EventCreatePage() {
)} )}
contentEdit={({ context, history }) => ( contentEdit={({ context, history }) => (
<ContentEditStep <ContentEditStep
initialTitle={context.recommendation?.title || ''} initialTitle={context.recommendation?.recommendation.title || ''}
initialPrize={context.recommendation?.prize || ''} initialPrize={''}
onNext={(contentEdit) => { onNext={(contentEdit) => {
history.push('approval', { ...context, contentEdit }); history.push('approval', { ...context, contentEdit });
}} }}

View File

@ -120,7 +120,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)', textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}} }}
> >
{eventData.recommendation?.title || '이벤트 제목'} {eventData.recommendation?.recommendation.title || '이벤트 제목'}
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
@ -158,7 +158,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
textShadow: '0px 2px 4px rgba(0,0,0,0.15)', 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={{ <Typography component="span" sx={{
fontSize: '1rem', fontSize: '1rem',
ml: 0.5, 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)', 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={{ <Typography component="span" sx={{
fontSize: '1rem', fontSize: '1rem',
ml: 0.5, 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)', textShadow: '0px 2px 4px rgba(0,0,0,0.15)',
}} }}
> >
{eventData.recommendation?.roi || 0}% {eventData.recommendation?.recommendation.expectedMetrics.roi.max || 0}%
</Typography> </Typography>
</CardContent> </CardContent>
</Card> </Card>
@ -270,7 +270,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography> </Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}> <Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.title} {eventData.recommendation?.recommendation.title}
</Typography> </Typography>
</Box> </Box>
<IconButton size="small"> <IconButton size="small">
@ -288,7 +288,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography> </Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}> <Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.prize} {eventData.recommendation?.recommendation.mechanics.details || ''}
</Typography> </Typography>
</Box> </Box>
<IconButton size="small"> <IconButton size="small">
@ -306,7 +306,7 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
</Typography> </Typography>
<Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}> <Typography variant="body1" sx={{ ...responsiveText.body1, fontWeight: 600, mt: 1 }}>
{eventData.recommendation?.participationMethod} {eventData.recommendation?.recommendation.mechanics.details || ''}
</Typography> </Typography>
</Box> </Box>
</Box> </Box>

View File

@ -67,13 +67,16 @@ const imageStyles: ImageStyle[] = [
]; ];
interface ContentPreviewStepProps { interface ContentPreviewStepProps {
eventId?: string;
eventTitle?: string;
eventDescription?: string;
onNext: (imageStyle: string, images: ImageInfo[]) => void; onNext: (imageStyle: string, images: ImageInfo[]) => void;
onSkip: () => void; onSkip: () => void;
onBack: () => void; onBack: () => void;
} }
interface EventCreationData { interface EventCreationData {
eventDraftId: string; // Changed from number to string eventDraftId: string;
eventTitle: string; eventTitle: string;
eventDescription: string; eventDescription: string;
industry: string; industry: string;
@ -83,6 +86,9 @@ interface EventCreationData {
} }
export default function ContentPreviewStep({ export default function ContentPreviewStep({
eventId: propsEventId,
eventTitle: propsEventTitle,
eventDescription: propsEventDescription,
onNext, onNext,
onSkip, onSkip,
onBack, onBack,
@ -112,25 +118,35 @@ export default function ContentPreviewStep({
handleGenerateImagesAuto(data); handleGenerateImagesAuto(data);
} }
}); });
} else { } else if (propsEventId) {
// Mock 데이터가 없으면 자동으로 설정 // Props에서 받은 이벤트 데이터 사용 (localStorage 없을 때만)
const mockData: EventCreationData = { console.log('✅ Using event data from props:', propsEventId);
eventDraftId: "1761634317010", // Changed to string const data: EventCreationData = {
eventTitle: "맥주 파티 이벤트", eventDraftId: propsEventId,
eventDescription: "강남에서 열리는 신나는 맥주 파티에 참여하세요!", eventTitle: propsEventTitle || '',
industry: "음식점", eventDescription: propsEventDescription || '',
location: "강남", industry: '',
trends: ["파티", "맥주", "생맥주"], location: '',
prize: "생맥주 1잔" trends: [],
prize: '',
}; };
setEventData(data);
console.log('⚠️ localStorage에 이벤트 데이터가 없습니다. Mock 데이터를 사용합니다.'); // 이미지 조회 시도
localStorage.setItem('eventCreationData', JSON.stringify(mockData)); loadImages(data).then((hasImages) => {
setEventData(mockData); if (!hasImages) {
loadImages(mockData); console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} else {
// 이벤트 데이터가 없으면 에러 표시
console.error('❌ No event data available. Cannot proceed.');
setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.');
setLoading(false);
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [propsEventId, propsEventTitle, propsEventDescription]);
const loadImages = async (data: EventCreationData): Promise<boolean> => { const loadImages = async (data: EventCreationData): Promise<boolean> => {
try { try {

View File

@ -1,4 +1,6 @@
import { useState } from 'react'; 'use client';
import { useState, useEffect } from 'react';
import { import {
Box, Box,
Container, Container,
@ -13,11 +15,12 @@ import {
RadioGroup, RadioGroup,
FormControlLabel, FormControlLabel,
IconButton, IconButton,
Tabs, CircularProgress,
Tab, Alert,
} from '@mui/material'; } from '@mui/material';
import { ArrowBack, Edit, Insights } from '@mui/icons-material'; import { ArrowBack, Edit, Insights } from '@mui/icons-material';
import { EventObjective, BudgetLevel, EventMethod } from '../page'; import { EventObjective, BudgetLevel, EventMethod } from '../page';
import { aiApi, eventApi, AIRecommendationResult, EventRecommendation } from '@/shared/api';
// 디자인 시스템 색상 // 디자인 시스템 색상
const colors = { 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 { interface RecommendationStepProps {
objective?: EventObjective; objective?: EventObjective;
onNext: (data: Recommendation) => void; eventId?: string; // 이전 단계에서 생성된 eventId
onNext: (data: {
recommendation: EventRecommendation;
eventId: string;
}) => void;
onBack: () => void; onBack: () => void;
} }
export default function RecommendationStep({ onNext, onBack }: RecommendationStepProps) { export default function RecommendationStep({
const [selectedBudget, setSelectedBudget] = useState<BudgetLevel>('low'); objective,
const [selected, setSelected] = useState<string | null>(null); eventId: initialEventId,
const [editedData, setEditedData] = useState<Record<string, { title: string; prize: string }>>({}); 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 = () => { // 컴포넌트 마운트 시 AI 추천 요청
const selectedRec = mockRecommendations.find((r) => r.id === selected); useEffect(() => {
if (selectedRec && selected) { if (!eventId && objective) {
const edited = editedData[selected]; // Step 1: 이벤트 생성
onNext({ createEventAndRequestAI();
...selectedRec, } else if (eventId) {
title: edited?.title || selectedRec.title, // 이미 eventId가 있으면 AI 추천 요청
prize: edited?.prize || selectedRec.prize, 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) => ({ setEditedData((prev) => ({
...prev, ...prev,
[id]: { ...prev[id], title }, [optionNumber]: {
...prev[optionNumber],
title
},
})); }));
}; };
const handleEditPrize = (id: string, prize: string) => { const handleEditDescription = (optionNumber: number, description: string) => {
setEditedData((prev) => ({ setEditedData((prev) => ({
...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 ( return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}> <Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}> <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 variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
📍 📍
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}> {aiResult.trendAnalysis.industryTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
</Typography> {trend.description}
</Typography>
))}
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}> <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
🗺 🗺
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}> {aiResult.trendAnalysis.regionalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
</Typography> {trend.description}
</Typography>
))}
</Grid> </Grid>
<Grid item xs={12} md={4}> <Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}> <Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, fontSize: '1rem' }}>
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}> {aiResult.trendAnalysis.seasonalTrends.slice(0, 3).map((trend, idx) => (
<Typography key={idx} variant="body2" color="text.secondary" sx={{ fontSize: '0.95rem', mb: 1 }}>
</Typography> {trend.description}
</Typography>
))}
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
</Card> </Card>
{/* Budget Selection */} {/* AI Recommendations */}
<Box sx={{ mb: 8 }}> <Box sx={{ mb: 8 }}>
<Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}> <Typography variant="h6" sx={{ fontWeight: 700, mb: 4, fontSize: '1.25rem' }}>
AI ({aiResult.recommendations.length} )
</Typography> </Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 6, fontSize: '1rem' }}>
2 ( 1, 1) . .
</Typography> </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> </Box>
{/* Recommendations */} {/* 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 }}> <Grid container spacing={6} sx={{ mb: 10 }}>
{budgetRecommendations.map((rec) => ( {aiResult.recommendations.map((rec) => (
<Grid item xs={12} md={6} key={rec.id}> <Grid item xs={12} key={rec.optionNumber}>
<Card <Card
elevation={0} elevation={0}
sx={{ sx={{
cursor: 'pointer', cursor: 'pointer',
borderRadius: 4, borderRadius: 4,
border: selected === rec.id ? 2 : 1, border: selected === rec.optionNumber ? 2 : 1,
borderColor: selected === rec.id ? colors.purple : 'divider', borderColor: selected === rec.optionNumber ? colors.purple : 'divider',
bgcolor: selected === rec.id ? `${colors.purpleLight}40` : 'background.paper', bgcolor: selected === rec.optionNumber ? `${colors.purpleLight}40` : 'background.paper',
transition: 'all 0.2s', 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': { '&:hover': {
borderColor: colors.purple, borderColor: colors.purple,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
transform: 'translateY(-2px)', transform: 'translateY(-2px)',
}, },
}} }}
onClick={() => setSelected(rec.id)} onClick={() => setSelected(rec.optionNumber)}
> >
<CardContent sx={{ p: 6 }}> <CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
<Chip <Box sx={{ display: 'flex', gap: 2 }}>
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'} <Chip
color={rec.method === 'online' ? 'primary' : 'secondary'} label={`옵션 ${rec.optionNumber}`}
size="medium" color="primary"
sx={{ fontSize: '0.875rem', py: 2 }} 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> </Box>
<TextField <TextField
fullWidth fullWidth
variant="outlined" variant="outlined"
value={editedData[rec.id]?.title || rec.title} value={editedData[rec.optionNumber]?.title || rec.title}
onChange={(e) => handleEditTitle(rec.id, e.target.value)} onChange={(e) => handleEditTitle(rec.optionNumber, e.target.value)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
sx={{ mb: 4 }} sx={{ mb: 4 }}
InputProps={{ InputProps={{
endAdornment: <Edit fontSize="small" color="action" />, endAdornment: <Edit fontSize="small" color="action" />,
sx: { fontSize: '1rem', py: 2 }, sx: { fontSize: '1.1rem', fontWeight: 600, py: 2 },
}} }}
/> />
<Box sx={{ mb: 4 }}> <TextField
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}> fullWidth
multiline
</Typography> rows={2}
<TextField variant="outlined"
fullWidth value={editedData[rec.optionNumber]?.description || rec.description}
size="medium" onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
variant="outlined" onClick={(e) => e.stopPropagation()}
value={editedData[rec.id]?.prize || rec.prize} sx={{ mb: 4 }}
onChange={(e) => handleEditPrize(rec.id, e.target.value)} InputProps={{
onClick={(e) => e.stopPropagation()} sx: { fontSize: '1rem' },
InputProps={{ }}
endAdornment: <Edit fontSize="small" color="action" />, />
sx: { fontSize: '1rem' },
}}
/>
</Box>
<Grid container spacing={4} sx={{ mt: 4 }}> <Grid container spacing={4} sx={{ mt: 2 }}>
<Grid item xs={6}> <Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}> <Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}>
{rec.participationMethod} {rec.targetAudience}
</Typography> </Typography>
</Grid> </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.expectedParticipants}
</Typography>
</Grid>
<Grid item xs={6}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}> <Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}>
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 600, fontSize: '1rem', mt: 1 }}> <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> </Typography>
</Grid> </Grid>
<Grid item xs={6}> <Grid item xs={6} md={3}>
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.875rem' }}> <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>
<Typography variant="body2" sx={{ fontWeight: 600, color: 'error.main', fontSize: '1rem', mt: 1 }}> <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> </Typography>
</Grid> </Grid>
</Grid> </Grid>
@ -381,7 +543,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
fullWidth fullWidth
variant="contained" variant="contained"
size="large" size="large"
disabled={!selected} disabled={selected === null || loading}
onClick={handleNext} onClick={handleNext}
sx={{ sx={{
py: 3, py: 3,
@ -398,7 +560,7 @@ export default function RecommendationStep({ onNext, onBack }: RecommendationSte
}, },
}} }}
> >
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
</Button> </Button>
</Box> </Box>
</Container> </Container>

178
src/shared/api/aiApi.ts Normal file
View 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
View 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;

View File

@ -1,2 +1,6 @@
export { apiClient } from './client'; export { apiClient, participationClient } from './client';
export type { ApiError } from './types'; export type { ApiError } from './types';
export * from './contentApi';
export * from './aiApi';
export * from './eventApi';
export * from './participation.api';