Merge remote-tracking branch 'origin/develop' into feature/analytics

This commit is contained in:
Hyowon Yang
2025-10-30 09:48:45 +09:00
27 changed files with 4182 additions and 415 deletions
+58 -14
View File
@@ -15,19 +15,50 @@ export type BudgetLevel = 'low' | 'medium' | 'high';
export type EventMethod = 'online' | 'offline';
export interface EventData {
eventDraftId?: number;
objective?: EventObjective;
recommendation?: {
budget: BudgetLevel;
method: EventMethod;
title: string;
prize: string;
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;
images?: any[];
};
contentEdit?: {
title: string;
@@ -89,6 +120,18 @@ export default function EventCreatePage() {
);
if (needsContent) {
// localStorage에 이벤트 정보 저장
const eventData = {
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));
history.push('contentPreview', { ...context, channels });
} else {
history.push('approval', { ...context, channels });
@@ -101,12 +144,13 @@ export default function EventCreatePage() {
)}
contentPreview={({ context, history }) => (
<ContentPreviewStep
title={context.recommendation?.title || ''}
prize={context.recommendation?.prize || ''}
onNext={(imageStyle) => {
eventId={context.recommendation?.eventId}
eventTitle={context.recommendation?.recommendation.title}
eventDescription={context.recommendation?.recommendation.description}
onNext={(imageStyle, images) => {
history.push('contentEdit', {
...context,
contentPreview: { imageStyle },
contentPreview: { imageStyle, images },
});
}}
onSkip={() => {
@@ -119,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 });
}}
@@ -20,6 +20,8 @@ import {
import { ArrowBack, CheckCircle, Edit, RocketLaunch, Save, People, AttachMoney, TrendingUp } from '@mui/icons-material';
import { EventData } from '../page';
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
import { eventApi } from '@/entities/event/api/eventApi';
import type { EventObjective } from '@/entities/event/model/types';
interface ApprovalStepProps {
eventData: EventData;
@@ -33,16 +35,98 @@ export default function ApprovalStep({ eventData, onApprove, onBack }: ApprovalS
const [successDialogOpen, setSuccessDialogOpen] = useState(false);
const [isDeploying, setIsDeploying] = useState(false);
const handleApprove = () => {
const handleApprove = async () => {
if (!agreeTerms) return;
setIsDeploying(true);
// 배포 시뮬레이션
setTimeout(() => {
try {
// 1. 이벤트 생성 API 호출
console.log('📞 Creating event with objective:', eventData.objective);
// objective 매핑 (Frontend → Backend)
const objectiveMap: Record<string, EventObjective> = {
'new_customer': 'CUSTOMER_ACQUISITION',
'revisit': 'Customer Retention',
'sales': 'Sales Promotion',
'awareness': 'awareness',
};
const backendObjective: EventObjective = (objectiveMap[eventData.objective || 'new_customer'] || 'CUSTOMER_ACQUISITION') as EventObjective;
const createResponse = await eventApi.createEvent({
objective: backendObjective,
});
console.log('✅ Event created:', createResponse);
if (createResponse.success && createResponse.data) {
const eventId = createResponse.data.eventId;
console.log('🎯 Event ID:', eventId);
// 2. 이벤트 상세 정보 업데이트
console.log('📞 Updating event details:', eventId);
// 이벤트명 가져오기 (contentEdit.title 또는 recommendation.title)
const eventName = eventData.contentEdit?.title || eventData.recommendation?.recommendation?.title || '이벤트';
// 날짜 설정 (오늘부터 30일간)
const today = new Date();
const endDate = new Date(today);
endDate.setDate(endDate.getDate() + 30);
const startDateStr = today.toISOString().split('T')[0]; // YYYY-MM-DD
const endDateStr = endDate.toISOString().split('T')[0];
await eventApi.updateEvent(eventId, {
eventName: eventName,
description: eventData.contentEdit?.guide || eventData.recommendation?.recommendation?.description || '',
startDate: startDateStr,
endDate: endDateStr,
});
console.log('✅ Event details updated');
// 3. 배포 채널 선택
if (eventData.channels && eventData.channels.length > 0) {
console.log('📞 Selecting channels:', eventData.channels);
// 채널명 매핑 (Frontend → Backend)
const channelMap: Record<string, string> = {
'uriTV': 'WEBSITE',
'ringoBiz': 'EMAIL',
'genieTV': 'KAKAO',
'sns': 'INSTAGRAM',
};
const backendChannels = eventData.channels.map(ch => channelMap[ch] || ch.toUpperCase());
await eventApi.selectChannels(eventId, {
channels: backendChannels,
});
console.log('✅ Channels selected');
}
// 4. TODO: 이미지 선택
// 현재 frontend에서 selectedImageId를 추적하지 않음
// 향후 contentPreview 단계에서 선택된 이미지 ID를 eventData에 저장 필요
console.log('⚠️ Image selection skipped - imageId not tracked in frontend');
// 5. 이벤트 배포 API 호출
console.log('📞 Publishing event:', eventId);
const publishResponse = await eventApi.publishEvent(eventId);
console.log('✅ Event published:', publishResponse);
// 성공 다이얼로그 표시
setIsDeploying(false);
setSuccessDialogOpen(true);
} else {
throw new Error('Event creation failed: No event ID returned');
}
} catch (error) {
console.error('❌ Event deployment failed:', error);
setIsDeploying(false);
setSuccessDialogOpen(true);
}, 2000);
alert('이벤트 배포에 실패했습니다. 다시 시도해 주세요.');
}
};
const handleSaveDraft = () => {
@@ -120,7 +204,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 +242,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 +288,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 +334,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 +354,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 +372,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 +390,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>
@@ -12,8 +12,11 @@ import {
IconButton,
Dialog,
Grid,
Alert,
} from '@mui/material';
import { ArrowBack, ZoomIn, Psychology } from '@mui/icons-material';
import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material';
import { contentApi, ImageInfo } from '@/shared/api/contentApi';
import Image from 'next/image';
// 디자인 시스템 색상
const colors = {
@@ -34,7 +37,7 @@ const colors = {
};
interface ImageStyle {
id: string;
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
name: string;
gradient?: string;
icon: string;
@@ -43,19 +46,19 @@ interface ImageStyle {
const imageStyles: ImageStyle[] = [
{
id: 'simple',
id: 'SIMPLE',
name: '스타일 1: 심플',
icon: 'celebration',
},
{
id: 'fancy',
id: 'FANCY',
name: '스타일 2: 화려',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'auto_awesome',
textColor: 'white',
},
{
id: 'trendy',
id: 'TRENDY',
name: '스타일 3: 트렌디',
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'trending_up',
@@ -64,50 +67,246 @@ const imageStyles: ImageStyle[] = [
];
interface ContentPreviewStepProps {
title: string;
prize: string;
onNext: (imageStyle: string) => void;
eventId?: string;
eventTitle?: string;
eventDescription?: string;
onNext: (imageStyle: string, images: ImageInfo[]) => void;
onSkip: () => void;
onBack: () => void;
}
interface EventCreationData {
eventDraftId: string;
eventTitle: string;
eventDescription: string;
industry: string;
location: string;
trends: string[];
prize: string;
}
export default function ContentPreviewStep({
title,
prize,
eventId: propsEventId,
eventTitle: propsEventTitle,
eventDescription: propsEventDescription,
onNext,
onSkip,
onBack,
}: ContentPreviewStepProps) {
const [loading, setLoading] = useState(true);
const [selectedStyle, setSelectedStyle] = useState<string | null>(null);
const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
const [fullscreenStyle, setFullscreenStyle] = useState<ImageStyle | null>(null);
const [fullscreenImage, setFullscreenImage] = useState<ImageInfo | null>(null);
const [generatedImages, setGeneratedImages] = useState<Map<string, ImageInfo>>(new Map());
const [error, setError] = useState<string | null>(null);
const [loadingProgress, setLoadingProgress] = useState(0);
const [loadingMessage, setLoadingMessage] = useState('이미지 생성 요청 중...');
const [eventData, setEventData] = useState<EventCreationData | null>(null);
useEffect(() => {
// AI 이미지 생성 시뮬레이션
const timer = setTimeout(() => {
// localStorage에서 이벤트 데이터 읽기
const storedData = localStorage.getItem('eventCreationData');
if (storedData) {
const data: EventCreationData = JSON.parse(storedData);
setEventData(data);
// 먼저 이미지 조회 시도
loadImages(data).then((hasImages) => {
// 이미지가 없으면 자동으로 생성
if (!hasImages) {
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} 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);
// 이미지 조회 시도
loadImages(data).then((hasImages) => {
if (!hasImages) {
console.log('📸 이미지가 없습니다. 자동으로 생성을 시작합니다...');
handleGenerateImagesAuto(data);
}
});
} else {
// 이벤트 데이터가 없으면 에러 표시
console.error('❌ No event data available. Cannot proceed.');
setError('이벤트 정보를 찾을 수 없습니다. 이전 단계로 돌아가 주세요.');
setLoading(false);
}, 5000);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [propsEventId, propsEventTitle, propsEventDescription]);
return () => clearTimeout(timer);
}, []);
const loadImages = async (data: EventCreationData): Promise<boolean> => {
try {
setError(null);
const handleStyleSelect = (styleId: string) => {
console.log('📥 Loading images for event:', data.eventDraftId);
const images = await contentApi.getImages(data.eventDraftId);
console.log('✅ Images loaded from API:', images.length, images);
if (!images || images.length === 0) {
console.warn('⚠️ No images found.');
return false; // 이미지 없음
}
const imageMap = new Map<string, ImageInfo>();
// 각 스타일별로 가장 최신 이미지만 선택 (createdAt 기준)
images.forEach((image, index) => {
console.log(`📸 Processing image ${index + 1}:`, {
id: image.id,
eventId: image.eventId,
style: image.style,
platform: image.platform,
cdnUrl: image.cdnUrl?.substring(0, 50) + '...',
createdAt: image.createdAt,
});
if (image.platform === 'INSTAGRAM') {
const existing = imageMap.get(image.style);
if (!existing || new Date(image.createdAt) > new Date(existing.createdAt)) {
console.log(` ✅ Selected as latest ${image.style} image`);
imageMap.set(image.style, image);
} else {
console.log(` ⏭️ Skipped (older than existing ${image.style} image)`);
}
} else {
console.log(` ⏭️ Skipped (platform: ${image.platform})`);
}
});
console.log('🎨 Image map created with entries:', {
SIMPLE: imageMap.has('SIMPLE') ? 'YES ✅' : 'NO ❌',
FANCY: imageMap.has('FANCY') ? 'YES ✅' : 'NO ❌',
TRENDY: imageMap.has('TRENDY') ? 'YES ✅' : 'NO ❌',
totalSize: imageMap.size,
});
console.log('🖼️ Image map details:', Array.from(imageMap.entries()).map(([style, img]) => ({
style,
id: img.id,
eventId: img.eventId,
cdnUrl: img.cdnUrl?.substring(0, 60) + '...',
})));
setGeneratedImages(imageMap);
console.log('✅ Images loaded successfully!');
return true; // 이미지 있음
} catch (err) {
console.error('❌ Load images error:', err);
// API 에러는 polling에서 무시 (계속 시도)
return false;
}
};
const handleStyleSelect = (styleId: 'SIMPLE' | 'FANCY' | 'TRENDY') => {
setSelectedStyle(styleId);
};
const handlePreview = (style: ImageStyle, e: React.MouseEvent) => {
const handlePreview = (image: ImageInfo, e: React.MouseEvent) => {
e.stopPropagation();
setFullscreenStyle(style);
setFullscreenImage(image);
setFullscreenOpen(true);
};
const handleNext = () => {
if (selectedStyle) {
onNext(selectedStyle);
const allImages = Array.from(generatedImages.values());
onNext(selectedStyle, allImages);
}
};
const handleGenerateImagesAuto = async (data: EventCreationData) => {
try {
setLoading(true);
setError(null);
setLoadingProgress(0);
setLoadingMessage('이미지 생성 요청 중...');
console.log('🎨 Auto-generating images for event:', data.eventDraftId);
// 이미지 생성 요청 (202 Accepted 응답만 확인)
await contentApi.generateImages({
eventId: data.eventDraftId,
eventTitle: data.eventTitle,
eventDescription: data.eventDescription,
industry: data.industry,
location: data.location,
trends: data.trends,
styles: ['SIMPLE', 'FANCY', 'TRENDY'],
platforms: ['INSTAGRAM'],
});
console.log('✅ Image generation request accepted (202)');
console.log('⏳ AI 이미지 생성 중... 약 60초 소요됩니다.');
setLoadingProgress(10);
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
// 생성 완료까지 대기 (polling)
let attempts = 0;
const maxAttempts = 30; // 최대 60초 (2초 * 30회)
const pollImages = async () => {
attempts++;
console.log(`🔄 이미지 확인 시도 ${attempts}/${maxAttempts}...`);
// 진행률 업데이트 (10% ~ 90%)
const progress = Math.min(10 + (attempts / maxAttempts) * 80, 90);
setLoadingProgress(progress);
// 단계별 메시지 업데이트
if (attempts < 10) {
setLoadingMessage('AI가 이미지를 생성하고 있어요...');
} else if (attempts < 20) {
setLoadingMessage('스타일을 적용하고 있어요...');
} else {
setLoadingMessage('거의 완료되었어요...');
}
const hasImages = await loadImages(data);
if (hasImages) {
console.log('✅ 이미지 생성 완료!');
setLoadingProgress(100);
setLoadingMessage('이미지 생성 완료!');
setTimeout(() => setLoading(false), 500); // 100% 잠깐 보여주기
} else if (attempts < maxAttempts) {
// 2초 후 다시 시도
setTimeout(pollImages, 2000);
} else {
console.warn('⚠️ 이미지 생성 시간 초과. "이미지 재생성" 버튼을 클릭하세요.');
setError('이미지 생성이 완료되지 않았습니다. 잠시 후 "이미지 재생성" 버튼을 클릭해주세요.');
setLoading(false);
}
};
// 첫 번째 확인은 5초 후 시작 (생성 시작 시간 고려)
setTimeout(pollImages, 5000);
} catch (err) {
console.error('❌ Image generation request error:', err);
setError('이미지 생성 요청에 실패했습니다.');
setLoading(false);
}
};
const handleGenerateImages = async () => {
if (!eventData) return;
handleGenerateImagesAuto(eventData);
};
if (loading) {
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
@@ -121,7 +320,7 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15 }}>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
{/* 그라데이션 스피너 */}
<Box
sx={{
@@ -161,17 +360,69 @@ export default function ContentPreviewStep({
}}
/>
</Box>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, fontSize: '1.5rem' }}>
AI
</Typography>
{/* 진행률 바 */}
<Box sx={{ mb: 4 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
{loadingMessage}
</Typography>
<Typography variant="h6" sx={{ fontWeight: 600, color: colors.purple, fontSize: '1.25rem' }}>
{Math.round(loadingProgress)}%
</Typography>
</Box>
<Box
sx={{
width: '100%',
height: 8,
bgcolor: 'rgba(0,0,0,0.1)',
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
}}
>
<Box
sx={{
width: `${loadingProgress}%`,
height: '100%',
background: `linear-gradient(90deg, ${colors.purple}, ${colors.pink})`,
borderRadius: 4,
transition: 'width 0.3s ease-in-out',
}}
/>
</Box>
</Box>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3, fontSize: '1.125rem' }}>
<br />
...
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '1rem' }}>
시간: 5초
{generatedImages.size > 0 ? (
<>
<br />
!
</>
) : (
<>
AI가
<br />
60
</>
)}
</Typography>
{error && (
<Alert severity="error" sx={{ maxWidth: 400, mx: 'auto', mt: 3 }}>
{error}
<Button
variant="outlined"
size="small"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{ mt: 2 }}
>
</Button>
</Alert>
)}
</Box>
</Container>
</Box>
@@ -179,7 +430,18 @@ export default function ContentPreviewStep({
}
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default', pb: 20 }}>
<Box
sx={{
minHeight: '100vh',
bgcolor: 'background.default',
pb: 20,
animation: 'fadeIn 0.5s ease-in',
'@keyframes fadeIn': {
from: { opacity: 0, transform: 'translateY(10px)' },
to: { opacity: 1, transform: 'translateY(0)' },
},
}}
>
<Container maxWidth="lg" sx={{ pt: 8, pb: 8, px: { xs: 6, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: 8 }}>
@@ -191,11 +453,35 @@ export default function ContentPreviewStep({
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 8 }}>
{generatedImages.size > 0 && (
<Alert severity="success" sx={{ flex: 1, fontSize: '1rem' }}>
</Alert>
)}
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={handleGenerateImages}
sx={{
ml: 4,
py: 2,
px: 4,
borderRadius: 2,
fontSize: '1rem',
fontWeight: 600,
whiteSpace: 'nowrap',
}}
>
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 8, textAlign: 'center', fontSize: '1rem' }}>
</Typography>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value)}>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{imageStyles.map((style) => (
<Grid item xs={12} md={4} key={style.id}>
@@ -237,46 +523,82 @@ export default function ContentPreviewStep({
sx={{
width: '100%',
aspectRatio: '1 / 1',
background: style.gradient || colors.gray[100],
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
position: 'relative',
overflow: 'hidden',
bgcolor: colors.gray[100],
}}
>
<span
className="material-icons"
style={{
fontSize: 64,
marginBottom: 24,
color: style.textColor || colors.gray[700],
}}
>
{style.icon}
</span>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 2,
color: style.textColor || 'text.primary',
fontSize: '1.25rem',
}}
>
{title}
</Typography>
<Typography
variant="body1"
sx={{
color: style.textColor || 'text.secondary',
opacity: style.textColor ? 0.9 : 1,
fontSize: '1rem',
}}
>
{prize}
</Typography>
{(() => {
const hasImage = generatedImages.has(style.id);
const imageData = generatedImages.get(style.id);
console.log(`🖼️ Rendering ${style.id}:`, {
hasImage,
imageDataExists: !!imageData,
fullCdnUrl: imageData?.cdnUrl,
mapSize: generatedImages.size,
mapKeys: Array.from(generatedImages.keys()),
});
return hasImage && imageData ? (
<Image
src={imageData.cdnUrl}
alt={style.name}
fill
style={{ objectFit: 'cover' }}
unoptimized
onLoad={() => console.log(`${style.id} image loaded successfully from:`, imageData.cdnUrl)}
onError={(e) => {
console.error(`${style.id} image load error:`, e);
console.error(` Failed URL:`, imageData.cdnUrl);
}}
/>
) : (
<Box
sx={{
width: '100%',
height: '100%',
background: style.gradient || colors.gray[100],
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
}}
>
<span
className="material-icons"
style={{
fontSize: 64,
marginBottom: 24,
color: style.textColor || colors.gray[700],
}}
>
{style.icon}
</span>
<Typography
variant="h6"
sx={{
fontWeight: 700,
mb: 2,
color: style.textColor || 'text.primary',
fontSize: '1.25rem',
}}
>
{eventData?.eventTitle || '이벤트'}
</Typography>
<Typography
variant="body1"
sx={{
color: style.textColor || 'text.secondary',
opacity: style.textColor ? 0.9 : 1,
fontSize: '1rem',
}}
>
{eventData?.prize || '경품'}
</Typography>
</Box>
);
})()}
</Box>
{/* 크게보기 버튼 */}
@@ -284,7 +606,13 @@ export default function ContentPreviewStep({
<Button
variant="outlined"
startIcon={<ZoomIn />}
onClick={(e) => handlePreview(style, e)}
onClick={(e) => {
const image = generatedImages.get(style.id);
if (image) {
handlePreview(image, e);
}
}}
disabled={!generatedImages.has(style.id)}
sx={{
borderRadius: 2,
py: 1.5,
@@ -387,51 +715,24 @@ export default function ContentPreviewStep({
<span className="material-icons">close</span>
</IconButton>
{fullscreenStyle && (
{fullscreenImage && (
<Box
sx={{
width: '100%',
maxWidth: 600,
maxWidth: 800,
aspectRatio: '1 / 1',
background: fullscreenStyle.gradient || '#f5f5f5',
position: 'relative',
borderRadius: 4,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 6,
textAlign: 'center',
overflow: 'hidden',
}}
>
<span
className="material-icons"
style={{
fontSize: 80,
marginBottom: 24,
color: fullscreenStyle.textColor || 'inherit',
}}
>
{fullscreenStyle.icon}
</span>
<Typography
variant="h4"
sx={{
fontWeight: 700,
mb: 2,
color: fullscreenStyle.textColor || 'text.primary',
}}
>
{title}
</Typography>
<Typography
variant="h6"
sx={{
color: fullscreenStyle.textColor || 'text.secondary',
opacity: fullscreenStyle.textColor ? 0.9 : 1,
}}
>
{prize}
</Typography>
<Image
src={fullscreenImage.cdnUrl}
alt={`${fullscreenImage.style} style`}
fill
style={{ objectFit: 'contain' }}
unoptimized
/>
</Box>
)}
</Box>
@@ -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 || err.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>
+123 -83
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import {
Box,
@@ -37,78 +37,12 @@ import {
} from '@mui/icons-material';
import Header from '@/shared/ui/Header';
import { cardStyles, colors, responsiveText } from '@/shared/lib/button-styles';
import { useEvents } from '@/entities/event/model/useEvents';
import type { EventStatus as ApiEventStatus } from '@/entities/event/model/types';
// Mock 데이터
const mockEvents = [
{
id: '1',
title: '신규고객 유치 이벤트',
status: 'active' as const,
daysLeft: 5,
participants: 128,
targetParticipants: 200,
roi: 450,
startDate: '2025-11-01',
endDate: '2025-11-15',
prize: '커피 쿠폰',
method: '전화번호 입력',
isUrgent: true,
isPopular: false,
isHighROI: true,
isNew: false,
},
{
id: '2',
title: '재방문 유도 이벤트',
status: 'active' as const,
daysLeft: 12,
participants: 56,
targetParticipants: 100,
roi: 320,
startDate: '2025-11-05',
endDate: '2025-11-20',
prize: '할인 쿠폰',
method: 'SNS 팔로우',
isUrgent: false,
isPopular: false,
isHighROI: false,
isNew: false,
},
{
id: '3',
title: '매출증대 프로모션',
status: 'ended' as const,
daysLeft: 0,
participants: 234,
targetParticipants: 150,
roi: 580,
startDate: '2025-10-15',
endDate: '2025-10-31',
prize: '상품권',
method: '구매 인증',
isUrgent: false,
isPopular: true,
isHighROI: true,
isNew: false,
},
{
id: '4',
title: '봄맞이 특별 이벤트',
status: 'scheduled' as const,
daysLeft: 30,
participants: 0,
targetParticipants: 300,
roi: 0,
startDate: '2025-12-01',
endDate: '2025-12-15',
prize: '체험권',
method: '이메일 등록',
isUrgent: false,
isPopular: false,
isHighROI: false,
isNew: true,
},
];
// ==================== API 연동 ====================
// Mock 데이터를 실제 API 호출로 교체
// 백업 파일: page.tsx.backup
type EventStatus = 'all' | 'active' | 'scheduled' | 'ended';
type Period = '1month' | '3months' | '6months' | '1year' | 'all';
@@ -123,8 +57,57 @@ export default function EventsPage() {
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
// API 데이터 가져오기
const { events: apiEvents, loading, error, pageInfo, refetch } = useEvents({
page: currentPage - 1,
size: itemsPerPage,
sort: 'createdAt',
order: 'desc'
});
// API 상태를 UI 상태로 매핑
const mapApiStatus = (apiStatus: ApiEventStatus): EventStatus => {
switch (apiStatus) {
case 'PUBLISHED':
return 'active';
case 'DRAFT':
return 'scheduled';
case 'ENDED':
return 'ended';
default:
return 'all';
}
};
// API 이벤트를 UI 형식으로 변환
const transformedEvents = apiEvents.map(event => ({
id: event.eventId,
title: event.eventName || '제목 없음',
status: mapApiStatus(event.status),
startDate: event.startDate ? new Date(event.startDate).toLocaleDateString('ko-KR') : '-',
endDate: event.endDate ? new Date(event.endDate).toLocaleDateString('ko-KR') : '-',
prize: event.aiRecommendations[0]?.reward || '경품 정보 없음',
method: event.aiRecommendations[0]?.participationMethod || '참여 방법 없음',
participants: event.participants || 0,
targetParticipants: event.targetParticipants || 0,
roi: event.roi || 0,
daysLeft: event.endDate
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
: 0,
isUrgent: event.endDate
? Math.ceil((new Date(event.endDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) <= 3
: false,
isPopular: event.participants && event.targetParticipants
? (event.participants / event.targetParticipants) >= 0.8
: false,
isHighROI: event.roi ? event.roi >= 300 : false,
isNew: event.createdAt
? (Date.now() - new Date(event.createdAt).getTime()) < (7 * 24 * 60 * 60 * 1000)
: false,
}));
// 필터링 및 정렬
const filteredEvents = mockEvents
const filteredEvents = transformedEvents
.filter((event) => {
const matchesSearch = event.title.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
@@ -204,22 +187,26 @@ export default function EventsPage() {
}
};
const calculateProgress = (event: (typeof mockEvents)[0]) => {
const calculateProgress = (event: typeof transformedEvents[0]) => {
if (event.status !== 'active') return 0;
const total = new Date(event.endDate).getTime() - new Date(event.startDate).getTime();
const elapsed = Date.now() - new Date(event.startDate).getTime();
const startTime = new Date(event.startDate).getTime();
const endTime = new Date(event.endDate).getTime();
const total = endTime - startTime;
const elapsed = Date.now() - startTime;
return Math.min(Math.max((elapsed / total) * 100, 0), 100);
};
// 통계 계산
const stats = {
total: mockEvents.length,
active: mockEvents.filter((e) => e.status === 'active').length,
totalParticipants: mockEvents.reduce((sum, e) => sum + e.participants, 0),
avgROI: Math.round(
mockEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
mockEvents.filter((e) => e.roi > 0).length
),
total: transformedEvents.length,
active: transformedEvents.filter((e) => e.status === 'active').length,
totalParticipants: transformedEvents.reduce((sum, e) => sum + e.participants, 0),
avgROI: transformedEvents.filter((e) => e.roi > 0).length > 0
? Math.round(
transformedEvents.filter((e) => e.roi > 0).reduce((sum, e) => sum + e.roi, 0) /
transformedEvents.filter((e) => e.roi > 0).length
)
: 0,
};
return (
@@ -237,6 +224,59 @@ export default function EventsPage() {
maxWidth="lg"
sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 6 }, px: { xs: 3, sm: 6, md: 10 } }}
>
{/* Loading State */}
{loading && (
<Box sx={{ mb: 4 }}>
<LinearProgress sx={{ borderRadius: 1 }} />
<Typography
sx={{
mt: 2,
textAlign: 'center',
color: colors.gray[600],
fontSize: { xs: '0.875rem', sm: '1rem' },
}}
>
...
</Typography>
</Box>
)}
{/* Error State */}
{error && (
<Card elevation={0} sx={{ ...cardStyles.default, mb: 4, bgcolor: '#FEE2E2' }}>
<CardContent sx={{ textAlign: 'center', py: 4 }}>
<Warning sx={{ fontSize: 48, color: '#DC2626', mb: 2 }} />
<Typography
variant="h6"
sx={{ mb: 1, color: '#991B1B', fontSize: { xs: '1rem', sm: '1.25rem' } }}
>
</Typography>
<Typography variant="body2" sx={{ color: '#7F1D1D', mb: 2 }}>
{error.message}
</Typography>
<Box
component="button"
onClick={() => refetch()}
sx={{
px: 3,
py: 1.5,
borderRadius: 2,
border: 'none',
bgcolor: '#DC2626',
color: 'white',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
'&:hover': { bgcolor: '#B91C1C' },
}}
>
</Box>
</CardContent>
</Card>
)}
{/* Summary Statistics */}
<Grid container spacing={{ xs: 2, sm: 4 }} sx={{ mb: { xs: 4, sm: 8 } }}>
<Grid item xs={6} sm={3}>
@@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ eventDraftId: string }> }
) {
try {
const { eventDraftId } = await context.params;
const { searchParams } = new URL(request.url);
const style = searchParams.get('style');
const platform = searchParams.get('platform');
// eventDraftId is now eventId in the API
let url = `${CONTENT_API_BASE_URL}/api/v1/content/events/${eventDraftId}/images`;
const queryParams = [];
if (style) queryParams.push(`style=${style}`);
if (platform) queryParams.push(`platform=${platform}`);
if (queryParams.length > 0) {
url += `?${queryParams.join('&')}`;
}
console.log('🔄 Proxying images request to Content API:', { url });
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
console.log('🔄 Proxying image generation request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/generate`,
body,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to generate images', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
console.log('✅ Image generation job created:', data);
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
const CONTENT_API_BASE_URL = process.env.NEXT_PUBLIC_CONTENT_API_URL || 'http://localhost:8084';
export async function GET(
request: NextRequest,
context: { params: Promise<{ jobId: string }> }
) {
try {
const { jobId } = await context.params;
console.log('🔄 Proxying job status request to Content API:', {
url: `${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`,
});
const response = await fetch(`${CONTENT_API_BASE_URL}/api/v1/content/images/jobs/${jobId}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ Content API error:', response.status, errorText);
return NextResponse.json(
{ error: 'Failed to get job status', details: errorText },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('❌ Proxy error:', error);
return NextResponse.json(
{ error: 'Internal server error', details: error instanceof Error ? error.message : 'Unknown error' },
{ status: 500 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from 'next/server';
/**
* Mock API: 이벤트 목적 선택 (Step 1)
* 백엔드 API가 준비될 때까지 사용하는 임시 Mock API
*
* POST /api/v1/events/objectives
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { objective } = body;
// 백엔드 API 호출 시도
const backendUrl = 'http://localhost:8080/api/events/objectives';
try {
const backendResponse = await fetch(backendUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': request.headers.get('Authorization') || '',
},
body: JSON.stringify(body),
});
// 백엔드가 정상 응답하면 그대로 반환
if (backendResponse.ok) {
const data = await backendResponse.json();
return NextResponse.json(data, { status: backendResponse.status });
}
} catch (backendError) {
console.warn('⚠️ 백엔드 API 호출 실패, Mock 데이터 반환:', backendError);
}
// 백엔드 실패 시 Mock 데이터 반환
// shared/api/eventApi의 selectObjective가 반환하는 형식과 일치
const mockEventId = `evt_${Date.now()}_${Math.random().toString(36).substring(7)}`;
const mockResponse = {
eventId: mockEventId,
objective: objective,
status: 'DRAFT' as const,
createdAt: new Date().toISOString(),
};
console.log('🎭 Mock API Response:', mockResponse);
return NextResponse.json(mockResponse, { status: 201 });
} catch (error) {
console.error('❌ Mock API Error:', error);
return NextResponse.json(
{
success: false,
errorCode: 'MOCK_ERROR',
message: 'Mock API 오류가 발생했습니다',
details: error instanceof Error ? error.message : 'Unknown error',
},
{ status: 500 }
);
}
}
+253
View File
@@ -0,0 +1,253 @@
import { apiClient } from '@/shared/api';
import type {
GetEventsRequest,
GetEventsResponse,
EventDetail,
ApiResponse,
SelectObjectiveRequest,
EventCreatedResponse,
AiRecommendationRequest,
JobAcceptedResponse,
ImageGenerationRequest,
ImageGenerationResponse,
UpdateEventRequest,
SelectChannelsRequest,
SelectImageRequest,
} from '../model/types';
/**
* Event API 기본 경로
*
* 참고: apiClient는 기본적으로 user-service(8081)를 가리키므로
* 별도의 event API 클라이언트를 사용하는 것이 좋습니다.
*
* 현재는 apiClient를 사용하되, baseURL을 오버라이드합니다.
*/
const EVENT_API_BASE = '/api/v1/events';
const EVENT_HOST = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
/**
* Event Service용 API 클라이언트
* Event Service는 별도 포트(8080)에서 실행되므로 별도 클라이언트 생성
*
* 로컬 개발 환경: Next.js rewrites 프록시 사용 (CORS 회피)
* 프로덕션 환경: 환경 변수에서 직접 호스트 사용
*/
import axios from 'axios';
const isProduction = process.env.NODE_ENV === 'production';
const API_BASE_URL = isProduction ? EVENT_HOST : ''; // 개발 환경에서는 상대 경로 사용
const eventApiClient = axios.create({
baseURL: API_BASE_URL,
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor - JWT 토큰 추가
eventApiClient.interceptors.request.use(
(config) => {
console.log('🚀 Event API Request:', {
method: config.method?.toUpperCase(),
url: config.url,
baseURL: config.baseURL,
params: config.params,
});
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
console.log('🔑 Token added to Event API request');
}
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,
statusText: error.response?.statusText,
url: error.config?.url,
data: error.response?.data,
});
if (error.response?.status === 401) {
console.warn('🔒 401 Unauthorized - Redirecting to login');
localStorage.removeItem('accessToken');
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
/**
* Event API Service
* 이벤트 관리 API
*/
export const eventApi = {
/**
* 이벤트 목록 조회
*/
getEvents: async (params?: GetEventsRequest): Promise<GetEventsResponse> => {
console.log('📞 eventApi.getEvents 호출', params);
const response = await eventApiClient.get<GetEventsResponse>(EVENT_API_BASE, {
params,
});
return response.data;
},
/**
* 이벤트 상세 조회
*/
getEvent: async (eventId: string): Promise<ApiResponse<EventDetail>> => {
console.log('📞 eventApi.getEvent 호출', eventId);
const response = await eventApiClient.get<ApiResponse<EventDetail>>(
`${EVENT_API_BASE}/${eventId}`
);
return response.data;
},
/**
* 이벤트 생성 (목적 선택)
*/
createEvent: async (
data: SelectObjectiveRequest
): Promise<ApiResponse<EventCreatedResponse>> => {
console.log('📞 eventApi.createEvent 호출', data);
const response = await eventApiClient.post<ApiResponse<EventCreatedResponse>>(
`${EVENT_API_BASE}/objectives`,
data
);
return response.data;
},
/**
* 이벤트 삭제
*/
deleteEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.deleteEvent 호출', eventId);
const response = await eventApiClient.delete<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}`
);
return response.data;
},
/**
* 이벤트 배포
*/
publishEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.publishEvent 호출', eventId);
const response = await eventApiClient.post<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/publish`
);
return response.data;
},
/**
* 이벤트 종료
*/
endEvent: async (eventId: string): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.endEvent 호출', eventId);
const response = await eventApiClient.post<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/end`
);
return response.data;
},
/**
* AI 추천 요청
*/
requestAiRecommendations: async (
eventId: string,
data: AiRecommendationRequest
): Promise<ApiResponse<JobAcceptedResponse>> => {
console.log('📞 eventApi.requestAiRecommendations 호출', eventId, data);
const response = await eventApiClient.post<ApiResponse<JobAcceptedResponse>>(
`${EVENT_API_BASE}/${eventId}/ai-recommendations`,
data
);
return response.data;
},
/**
* 이미지 생성 요청
*/
requestImageGeneration: async (
eventId: string,
data: ImageGenerationRequest
): Promise<ApiResponse<ImageGenerationResponse>> => {
console.log('📞 eventApi.requestImageGeneration 호출', eventId, data);
const response = await eventApiClient.post<ApiResponse<ImageGenerationResponse>>(
`${EVENT_API_BASE}/${eventId}/images`,
data
);
return response.data;
},
/**
* 이벤트 수정
*/
updateEvent: async (
eventId: string,
data: UpdateEventRequest
): Promise<ApiResponse<EventDetail>> => {
console.log('📞 eventApi.updateEvent 호출', eventId, data);
const response = await eventApiClient.put<ApiResponse<EventDetail>>(
`${EVENT_API_BASE}/${eventId}`,
data
);
return response.data;
},
/**
* 배포 채널 선택
*/
selectChannels: async (
eventId: string,
data: SelectChannelsRequest
): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.selectChannels 호출', eventId, data);
const response = await eventApiClient.put<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/channels`,
data
);
return response.data;
},
/**
* 이미지 선택
*/
selectImage: async (
eventId: string,
imageId: string,
data: SelectImageRequest
): Promise<ApiResponse<void>> => {
console.log('📞 eventApi.selectImage 호출', eventId, imageId, data);
const response = await eventApiClient.put<ApiResponse<void>>(
`${EVENT_API_BASE}/${eventId}/images/${imageId}/select`,
data
);
return response.data;
},
};
export default eventApi;
+198
View File
@@ -0,0 +1,198 @@
/**
* Event 도메인 타입 정의
* Event Service API 응답 형식과 일치
*/
/**
* 이벤트 상태
*/
export type EventStatus = 'DRAFT' | 'PUBLISHED' | 'ENDED';
/**
* 이벤트 목적
*/
export type EventObjective =
| 'CUSTOMER_ACQUISITION'
| 'Sales Promotion'
| 'Customer Retention'
| 'New Customer Acquisition'
| 'awareness'
| 'sales'
| 'new_customer';
/**
* 배포 채널
*/
export type DistributionChannel = 'SMS' | 'EMAIL' | 'KAKAO' | 'PUSH';
/**
* 이벤트 이미지
*/
export interface EventImage {
imageId: string;
imageUrl: string;
prompt?: string;
isSelected: boolean;
createdAt: string;
}
/**
* AI 추천
*/
export interface AiRecommendation {
recommendationId: string;
eventName: string;
description: string;
reward: string;
participationMethod: string;
startDate: string;
endDate: string;
targetParticipants: number;
isSelected: boolean;
createdAt: string;
}
/**
* 이벤트 상세 정보
*/
export interface EventDetail {
eventId: string;
userId: string;
storeId: string;
eventName: string;
description: string | null;
objective: EventObjective;
startDate: string | null;
endDate: string | null;
status: EventStatus;
selectedImageId: string | null;
selectedImageUrl: string | null;
participants: number | null;
targetParticipants: number | null;
roi: number | null;
generatedImages: EventImage[];
aiRecommendations: AiRecommendation[];
channels: DistributionChannel[];
createdAt: string;
updatedAt: string;
}
/**
* 페이지 응답
*/
export interface PageResponse<T> {
content: T[];
page: number;
size: number;
totalElements: number;
totalPages: number;
first: boolean;
last: boolean;
}
/**
* API 표준 응답
*/
export interface ApiResponse<T> {
success: boolean;
data: T;
timestamp: string;
}
/**
* 이벤트 목록 조회 요청
*/
export interface GetEventsRequest {
status?: EventStatus;
search?: string;
objective?: string;
page?: number;
size?: number;
sort?: string;
order?: 'asc' | 'desc';
}
/**
* 이벤트 목록 조회 응답
*/
export type GetEventsResponse = ApiResponse<PageResponse<EventDetail>>;
/**
* 이벤트 목적 선택 요청
*/
export interface SelectObjectiveRequest {
objective: EventObjective;
}
/**
* 이벤트 생성 응답
*/
export interface EventCreatedResponse {
eventId: string;
objective: EventObjective;
status: EventStatus;
createdAt: string;
}
/**
* AI 추천 요청
*/
export interface AiRecommendationRequest {
storeCategory?: string;
targetAudience?: string;
budget?: number;
additionalInfo?: string;
}
/**
* Job 수락 응답
*/
export interface JobAcceptedResponse {
jobId: string;
eventId: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
estimatedCompletionTime?: string;
}
/**
* 이미지 생성 요청
*/
export interface ImageGenerationRequest {
prompt: string;
numberOfImages?: number;
style?: string;
}
/**
* 이미지 생성 응답
*/
export interface ImageGenerationResponse {
jobId: string;
eventId: string;
status: string;
}
/**
* 이벤트 수정 요청
*/
export interface UpdateEventRequest {
eventName?: string;
description?: string;
startDate?: string;
endDate?: string;
discountRate?: number;
}
/**
* 배포 채널 선택 요청
*/
export interface SelectChannelsRequest {
channels: string[];
}
/**
* 이미지 선택 요청
*/
export interface SelectImageRequest {
selectedImageId: string;
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect } from 'react';
import { eventApi } from '../api/eventApi';
import type {
EventDetail,
GetEventsRequest,
EventStatus,
PageResponse,
} from './types';
/**
* useEvents Hook
* 이벤트 목록 조회 및 상태 관리
*/
export function useEvents(initialParams?: GetEventsRequest) {
const [events, setEvents] = useState<EventDetail[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [pageInfo, setPageInfo] = useState<Omit<PageResponse<EventDetail>, 'content'>>({
page: 0,
size: 20,
totalElements: 0,
totalPages: 0,
first: true,
last: true,
});
const fetchEvents = async (params?: GetEventsRequest) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Fetching events with params:', params);
const response = await eventApi.getEvents(params);
console.log('✅ Events fetched:', response);
if (response.success && response.data) {
setEvents(response.data.content);
setPageInfo({
page: response.data.page,
size: response.data.size,
totalElements: response.data.totalElements,
totalPages: response.data.totalPages,
first: response.data.first,
last: response.data.last,
});
}
} catch (err) {
console.error('❌ Error fetching events:', err);
setError(err as Error);
} finally {
setLoading(false);
}
};
// 초기 로드
useEffect(() => {
fetchEvents(initialParams);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
events,
loading,
error,
pageInfo,
refetch: fetchEvents,
};
}
/**
* useEvent Hook
* 단일 이벤트 조회 및 상태 관리
*/
export function useEvent(eventId: string) {
const [event, setEvent] = useState<EventDetail | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchEvent = async () => {
if (!eventId) return;
try {
setLoading(true);
setError(null);
console.log('🔄 Fetching event:', eventId);
const response = await eventApi.getEvent(eventId);
console.log('✅ Event fetched:', response);
if (response.success && response.data) {
setEvent(response.data);
}
} catch (err) {
console.error('❌ Error fetching event:', err);
setError(err as Error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEvent();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [eventId]);
return {
event,
loading,
error,
refetch: fetchEvent,
};
}
/**
* useEventActions Hook
* 이벤트 생성, 삭제, 배포 등의 액션 관리
*/
export function useEventActions() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const createEvent = async (objective: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Creating event with objective:', objective);
const response = await eventApi.createEvent({ objective: objective as any });
console.log('✅ Event created:', response);
return response.data;
} catch (err) {
console.error('❌ Error creating event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const deleteEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Deleting event:', eventId);
await eventApi.deleteEvent(eventId);
console.log('✅ Event deleted');
} catch (err) {
console.error('❌ Error deleting event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const publishEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Publishing event:', eventId);
await eventApi.publishEvent(eventId);
console.log('✅ Event published');
} catch (err) {
console.error('❌ Error publishing event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
const endEvent = async (eventId: string) => {
try {
setLoading(true);
setError(null);
console.log('🔄 Ending event:', eventId);
await eventApi.endEvent(eventId);
console.log('✅ Event ended');
} catch (err) {
console.error('❌ Error ending event:', err);
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
};
return {
createEvent,
deleteEvent,
publishEvent,
endEvent,
loading,
error,
};
}
+178
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;
+160
View File
@@ -0,0 +1,160 @@
import axios, { AxiosInstance } from 'axios';
// Use Next.js API proxy to bypass CORS issues
const CONTENT_API_BASE_URL = '/api/content';
export const contentApiClient: AxiosInstance = axios.create({
baseURL: CONTENT_API_BASE_URL,
timeout: 120000, // 이미지 생성은 시간이 오래 걸릴 수 있으므로 120초
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor
contentApiClient.interceptors.request.use(
(config) => {
console.log('🎨 Content 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('❌ Content API Request Error:', error);
return Promise.reject(error);
}
);
// Response interceptor
contentApiClient.interceptors.response.use(
(response) => {
console.log('✅ Content API Response:', {
status: response.status,
url: response.config.url,
data: response.data,
});
return response;
},
(error) => {
console.error('❌ Content API Error:', {
message: error.message,
status: error.response?.status,
url: error.config?.url,
data: error.response?.data,
});
return Promise.reject(error);
}
);
// Types
export interface GenerateImagesRequest {
eventId: string;
eventTitle: string;
eventDescription: string;
industry?: string;
location?: string;
trends?: string[];
styles: ('SIMPLE' | 'FANCY' | 'TRENDY')[];
platforms: ('INSTAGRAM' | 'NAVER' | 'KAKAO')[];
}
export interface JobInfo {
id: string;
eventId: string;
jobType: string;
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
progress: number;
resultMessage?: string;
errorMessage?: string;
createdAt: string;
updatedAt: string;
}
export interface ImageInfo {
id: number;
eventId: string;
style: 'SIMPLE' | 'FANCY' | 'TRENDY';
platform: 'INSTAGRAM' | 'NAVER' | 'KAKAO';
cdnUrl: string;
prompt: string;
selected: boolean;
createdAt: string;
updatedAt: string;
}
export interface ContentInfo {
id: number;
eventId: string;
eventTitle: string;
eventDescription: string;
images: ImageInfo[];
createdAt: string;
updatedAt: string;
}
// API Functions
export const contentApi = {
// 이미지 생성 (Next.js API proxy 사용)
generateImages: async (request: GenerateImagesRequest): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>('/images/generate', request);
return response.data;
},
// Job 상태 조회 (Next.js API proxy 사용)
getJobStatus: async (jobId: string): Promise<JobInfo> => {
const response = await contentApiClient.get<JobInfo>(`/images/jobs/${jobId}`);
return response.data;
},
// 이벤트별 콘텐츠 조회
getContentByEventId: async (eventId: string): Promise<ContentInfo> => {
const response = await contentApiClient.get<ContentInfo>(`/events/${eventId}`);
return response.data;
},
// 이미지 목록 조회 (Next.js API proxy 사용)
getImages: async (
eventId: string,
style?: 'SIMPLE' | 'FANCY' | 'TRENDY',
platform?: 'INSTAGRAM' | 'NAVER' | 'KAKAO'
): Promise<ImageInfo[]> => {
const params = new URLSearchParams();
if (style) params.append('style', style);
if (platform) params.append('platform', platform);
const response = await contentApiClient.get<ImageInfo[]>(
`/events/${eventId}/images${params.toString() ? `?${params.toString()}` : ''}`
);
return response.data;
},
// 특정 이미지 조회
getImageById: async (imageId: number): Promise<ImageInfo> => {
const response = await contentApiClient.get<ImageInfo>(`/api/v1/content/images/${imageId}`);
return response.data;
},
// 이미지 삭제
deleteImage: async (imageId: number): Promise<void> => {
await contentApiClient.delete(`/api/v1/content/images/${imageId}`);
},
// 이미지 재생성
regenerateImage: async (imageId: number, newPrompt?: string): Promise<JobInfo> => {
const response = await contentApiClient.post<JobInfo>(
`/api/v1/content/images/${imageId}/regenerate`,
{ imageId, newPrompt }
);
return response.data;
},
};
export default contentApi;
+334
View File
@@ -0,0 +1,334 @@
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 || 'v1';
// 개발 환경에서는 상대 경로 사용 (Next.js rewrites 프록시 또는 Mock API 사용)
// 프로덕션 환경에서는 환경 변수의 호스트 사용
const isProduction = process.env.NODE_ENV === 'production';
const BASE_URL = isProduction ? `${EVENT_API_BASE_URL}/api/${API_VERSION}` : `/api/${API_VERSION}`;
export const eventApiClient: AxiosInstance = axios.create({
baseURL: BASE_URL,
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;
+5 -1
View File
@@ -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';