mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 11:36:24 +00:00
Merge pull request #8 from ktds-dg0501/feature/ai
백엔드 AI 서비스와 프론트엔드 완전 연동
This commit is contained in:
commit
a82395e72a
@ -18,17 +18,43 @@ export interface EventData {
|
|||||||
eventDraftId?: number;
|
eventDraftId?: number;
|
||||||
objective?: EventObjective;
|
objective?: EventObjective;
|
||||||
recommendation?: {
|
recommendation?: {
|
||||||
budget: BudgetLevel;
|
recommendation: {
|
||||||
method: EventMethod;
|
optionNumber: number;
|
||||||
|
concept: string;
|
||||||
title: string;
|
title: string;
|
||||||
prize: string;
|
description: string;
|
||||||
description?: string;
|
targetAudience: string;
|
||||||
industry?: string;
|
duration: {
|
||||||
location?: string;
|
recommendedDays: number;
|
||||||
participationMethod: string;
|
recommendedPeriod?: string;
|
||||||
expectedParticipants: number;
|
};
|
||||||
estimatedCost: number;
|
mechanics: {
|
||||||
roi: number;
|
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 });
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 }}>
|
||||||
|
• {trend.description}
|
||||||
</Typography>
|
</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 }}>
|
||||||
|
• {trend.description}
|
||||||
</Typography>
|
</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 }}>
|
||||||
|
• {trend.description}
|
||||||
</Typography>
|
</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 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2 }}>
|
||||||
<Chip
|
<Chip
|
||||||
label={rec.method === 'online' ? '🌐 온라인' : '🏪 오프라인'}
|
label={`옵션 ${rec.optionNumber}`}
|
||||||
color={rec.method === 'online' ? 'primary' : 'secondary'}
|
color="primary"
|
||||||
size="medium"
|
size="medium"
|
||||||
sx={{ fontSize: '0.875rem', py: 2 }}
|
sx={{ fontSize: '0.875rem', py: 2 }}
|
||||||
/>
|
/>
|
||||||
<FormControlLabel value={rec.id} control={<Radio />} label="" sx={{ m: 0 }} />
|
<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 }}
|
||||||
|
/>
|
||||||
</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 }}>
|
|
||||||
<Typography variant="caption" color="text.secondary" sx={{ mb: 1, display: 'block', fontSize: '0.875rem' }}>
|
|
||||||
경품
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
<TextField
|
||||||
fullWidth
|
fullWidth
|
||||||
size="medium"
|
multiline
|
||||||
|
rows={2}
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
value={editedData[rec.id]?.prize || rec.prize}
|
value={editedData[rec.optionNumber]?.description || rec.description}
|
||||||
onChange={(e) => handleEditPrize(rec.id, e.target.value)}
|
onChange={(e) => handleEditDescription(rec.optionNumber, e.target.value)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
sx={{ mb: 4 }}
|
||||||
InputProps={{
|
InputProps={{
|
||||||
endAdornment: <Edit fontSize="small" color="action" />,
|
|
||||||
sx: { fontSize: '1rem' },
|
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
178
src/shared/api/aiApi.ts
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// AI Service API 클라이언트
|
||||||
|
const AI_API_BASE_URL = process.env.NEXT_PUBLIC_AI_HOST || 'http://localhost:8083';
|
||||||
|
|
||||||
|
export const aiApiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: AI_API_BASE_URL,
|
||||||
|
timeout: 300000, // AI 생성은 최대 5분
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
aiApiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
console.log('🤖 AI API Request:', {
|
||||||
|
method: config.method?.toUpperCase(),
|
||||||
|
url: config.url,
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
data: config.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('❌ AI API Request Error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
aiApiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
console.log('✅ AI API Response:', {
|
||||||
|
status: response.status,
|
||||||
|
url: response.config.url,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('❌ AI API Error:', {
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
url: error.config?.url,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface TrendKeyword {
|
||||||
|
keyword: string;
|
||||||
|
relevance: number;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrendAnalysis {
|
||||||
|
industryTrends: TrendKeyword[];
|
||||||
|
regionalTrends: TrendKeyword[];
|
||||||
|
seasonalTrends: TrendKeyword[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExpectedMetrics {
|
||||||
|
newCustomers: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
repeatVisits?: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
revenueIncrease: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
roi: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
};
|
||||||
|
socialEngagement?: {
|
||||||
|
estimatedPosts: number;
|
||||||
|
estimatedReach: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventRecommendation {
|
||||||
|
optionNumber: number;
|
||||||
|
concept: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
targetAudience: string;
|
||||||
|
duration: {
|
||||||
|
recommendedDays: number;
|
||||||
|
recommendedPeriod?: string;
|
||||||
|
};
|
||||||
|
mechanics: {
|
||||||
|
type: 'DISCOUNT' | 'GIFT' | 'STAMP' | 'EXPERIENCE' | 'LOTTERY' | 'COMBO';
|
||||||
|
details: string;
|
||||||
|
};
|
||||||
|
promotionChannels: string[];
|
||||||
|
estimatedCost: {
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
breakdown?: {
|
||||||
|
material?: number;
|
||||||
|
promotion?: number;
|
||||||
|
discount?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
expectedMetrics: ExpectedMetrics;
|
||||||
|
differentiator: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIRecommendationResult {
|
||||||
|
eventId: string;
|
||||||
|
trendAnalysis: TrendAnalysis;
|
||||||
|
recommendations: EventRecommendation[];
|
||||||
|
generatedAt: string;
|
||||||
|
expiresAt: string;
|
||||||
|
aiProvider: 'CLAUDE' | 'GPT4';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobStatusResponse {
|
||||||
|
jobId: string;
|
||||||
|
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||||
|
progress: number;
|
||||||
|
message: string;
|
||||||
|
eventId?: string;
|
||||||
|
createdAt: string;
|
||||||
|
startedAt?: string;
|
||||||
|
completedAt?: string;
|
||||||
|
failedAt?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
retryCount?: number;
|
||||||
|
processingTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HealthCheckResponse {
|
||||||
|
status: 'UP' | 'DOWN' | 'DEGRADED';
|
||||||
|
timestamp: string;
|
||||||
|
services: {
|
||||||
|
kafka: 'UP' | 'DOWN';
|
||||||
|
redis: 'UP' | 'DOWN';
|
||||||
|
claude_api: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
|
||||||
|
gpt4_api?: 'UP' | 'DOWN' | 'CIRCUIT_OPEN';
|
||||||
|
circuit_breaker: 'CLOSED' | 'OPEN' | 'HALF_OPEN';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
export const aiApi = {
|
||||||
|
// 헬스체크
|
||||||
|
healthCheck: async (): Promise<HealthCheckResponse> => {
|
||||||
|
const response = await aiApiClient.get<HealthCheckResponse>('/health');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Job 상태 조회 (Internal API)
|
||||||
|
getJobStatus: async (jobId: string): Promise<JobStatusResponse> => {
|
||||||
|
const response = await aiApiClient.get<JobStatusResponse>(`/internal/jobs/${jobId}/status`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI 추천 결과 조회 (Internal API)
|
||||||
|
getRecommendations: async (eventId: string): Promise<AIRecommendationResult> => {
|
||||||
|
const response = await aiApiClient.get<AIRecommendationResult>(`/internal/recommendations/${eventId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default aiApi;
|
||||||
329
src/shared/api/eventApi.ts
Normal file
329
src/shared/api/eventApi.ts
Normal file
@ -0,0 +1,329 @@
|
|||||||
|
import axios, { AxiosInstance } from 'axios';
|
||||||
|
|
||||||
|
// Event Service API 클라이언트
|
||||||
|
const EVENT_API_BASE_URL = process.env.NEXT_PUBLIC_EVENT_HOST || 'http://localhost:8080';
|
||||||
|
const API_VERSION = process.env.NEXT_PUBLIC_API_VERSION || 'api';
|
||||||
|
|
||||||
|
export const eventApiClient: AxiosInstance = axios.create({
|
||||||
|
baseURL: `${EVENT_API_BASE_URL}/${API_VERSION}`,
|
||||||
|
timeout: 30000, // Job 폴링 고려
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Request interceptor
|
||||||
|
eventApiClient.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
console.log('📅 Event API Request:', {
|
||||||
|
method: config.method?.toUpperCase(),
|
||||||
|
url: config.url,
|
||||||
|
baseURL: config.baseURL,
|
||||||
|
data: config.data,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
if (token && config.headers) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('❌ Event API Request Error:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor
|
||||||
|
eventApiClient.interceptors.response.use(
|
||||||
|
(response) => {
|
||||||
|
console.log('✅ Event API Response:', {
|
||||||
|
status: response.status,
|
||||||
|
url: response.config.url,
|
||||||
|
data: response.data,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
console.error('❌ Event API Error:', {
|
||||||
|
message: error.message,
|
||||||
|
status: error.response?.status,
|
||||||
|
url: error.config?.url,
|
||||||
|
data: error.response?.data,
|
||||||
|
});
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface EventObjectiveRequest {
|
||||||
|
objective: string; // "신규 고객 유치", "재방문 유도", "매출 증대", "브랜드 인지도 향상"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventCreatedResponse {
|
||||||
|
eventId: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||||
|
objective: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiRecommendationRequest {
|
||||||
|
storeInfo: {
|
||||||
|
storeId: string;
|
||||||
|
storeName: string;
|
||||||
|
category: string;
|
||||||
|
description?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobAcceptedResponse {
|
||||||
|
jobId: string;
|
||||||
|
status: 'PENDING';
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventJobStatusResponse {
|
||||||
|
jobId: string;
|
||||||
|
jobType: 'AI_RECOMMENDATION' | 'IMAGE_GENERATION';
|
||||||
|
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED';
|
||||||
|
progress: number;
|
||||||
|
resultKey?: string;
|
||||||
|
errorMessage?: string;
|
||||||
|
createdAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectRecommendationRequest {
|
||||||
|
recommendationId: string;
|
||||||
|
customizations?: {
|
||||||
|
eventName?: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
discountRate?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
eventInfo: {
|
||||||
|
eventName: string;
|
||||||
|
description: string;
|
||||||
|
promotionType: string;
|
||||||
|
};
|
||||||
|
imageCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectChannelsRequest {
|
||||||
|
channels: ('WEBSITE' | 'KAKAO' | 'INSTAGRAM' | 'FACEBOOK' | 'NAVER_BLOG')[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChannelDistributionResult {
|
||||||
|
channel: string;
|
||||||
|
success: boolean;
|
||||||
|
url?: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventPublishedResponse {
|
||||||
|
eventId: string;
|
||||||
|
status: 'PUBLISHED';
|
||||||
|
publishedAt: string;
|
||||||
|
channels: string[];
|
||||||
|
distributionResults: ChannelDistributionResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSummary {
|
||||||
|
eventId: string;
|
||||||
|
eventName: string;
|
||||||
|
objective: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
thumbnailUrl?: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageInfo {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventListResponse {
|
||||||
|
content: EventSummary[];
|
||||||
|
page: PageInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GeneratedImage {
|
||||||
|
imageId: string;
|
||||||
|
imageUrl: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AiRecommendation {
|
||||||
|
recommendationId: string;
|
||||||
|
eventName: string;
|
||||||
|
description: string;
|
||||||
|
promotionType: string;
|
||||||
|
targetAudience: string;
|
||||||
|
isSelected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventDetailResponse {
|
||||||
|
eventId: string;
|
||||||
|
userId: string;
|
||||||
|
storeId: string;
|
||||||
|
eventName: string;
|
||||||
|
objective: string;
|
||||||
|
description: string;
|
||||||
|
targetAudience: string;
|
||||||
|
promotionType: string;
|
||||||
|
discountRate?: number;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||||
|
selectedImageId?: string;
|
||||||
|
selectedImageUrl?: string;
|
||||||
|
generatedImages?: GeneratedImage[];
|
||||||
|
channels?: string[];
|
||||||
|
aiRecommendations?: AiRecommendation[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateEventRequest {
|
||||||
|
eventName?: string;
|
||||||
|
description?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
discountRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EndEventRequest {
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Functions
|
||||||
|
export const eventApi = {
|
||||||
|
// Step 1: 목적 선택 및 이벤트 생성
|
||||||
|
selectObjective: async (objective: string): Promise<EventCreatedResponse> => {
|
||||||
|
const response = await eventApiClient.post<EventCreatedResponse>('/events/objectives', {
|
||||||
|
objective,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 2: AI 추천 요청
|
||||||
|
requestAiRecommendations: async (
|
||||||
|
eventId: string,
|
||||||
|
storeInfo: AiRecommendationRequest['storeInfo']
|
||||||
|
): Promise<JobAcceptedResponse> => {
|
||||||
|
const response = await eventApiClient.post<JobAcceptedResponse>(
|
||||||
|
`/events/${eventId}/ai-recommendations`,
|
||||||
|
{ storeInfo }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Job 상태 폴링
|
||||||
|
getJobStatus: async (jobId: string): Promise<EventJobStatusResponse> => {
|
||||||
|
const response = await eventApiClient.get<EventJobStatusResponse>(`/jobs/${jobId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// AI 추천 선택
|
||||||
|
selectRecommendation: async (
|
||||||
|
eventId: string,
|
||||||
|
request: SelectRecommendationRequest
|
||||||
|
): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.put<EventDetailResponse>(
|
||||||
|
`/events/${eventId}/recommendations`,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 3: 이미지 생성 요청
|
||||||
|
requestImageGeneration: async (
|
||||||
|
eventId: string,
|
||||||
|
request: ImageGenerationRequest
|
||||||
|
): Promise<JobAcceptedResponse> => {
|
||||||
|
const response = await eventApiClient.post<JobAcceptedResponse>(`/events/${eventId}/images`, request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이미지 선택
|
||||||
|
selectImage: async (eventId: string, imageId: string): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.put<EventDetailResponse>(
|
||||||
|
`/events/${eventId}/images/${imageId}/select`
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 4: 이미지 편집
|
||||||
|
editImage: async (
|
||||||
|
eventId: string,
|
||||||
|
imageId: string,
|
||||||
|
editRequest: any
|
||||||
|
): Promise<{ imageId: string; imageUrl: string; editedAt: string }> => {
|
||||||
|
const response = await eventApiClient.put(`/events/${eventId}/images/${imageId}/edit`, editRequest);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 5: 배포 채널 선택
|
||||||
|
selectChannels: async (eventId: string, channels: string[]): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}/channels`, {
|
||||||
|
channels,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Step 6: 최종 배포
|
||||||
|
publishEvent: async (eventId: string): Promise<EventPublishedResponse> => {
|
||||||
|
const response = await eventApiClient.post<EventPublishedResponse>(`/events/${eventId}/publish`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트 목록 조회
|
||||||
|
getEvents: async (params?: {
|
||||||
|
status?: 'DRAFT' | 'PUBLISHED' | 'ENDED';
|
||||||
|
objective?: string;
|
||||||
|
search?: string;
|
||||||
|
page?: number;
|
||||||
|
size?: number;
|
||||||
|
sort?: string;
|
||||||
|
order?: 'asc' | 'desc';
|
||||||
|
}): Promise<EventListResponse> => {
|
||||||
|
const response = await eventApiClient.get<EventListResponse>('/events', { params });
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트 상세 조회
|
||||||
|
getEventDetail: async (eventId: string): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.get<EventDetailResponse>(`/events/${eventId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트 수정
|
||||||
|
updateEvent: async (eventId: string, request: UpdateEventRequest): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.put<EventDetailResponse>(`/events/${eventId}`, request);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트 삭제
|
||||||
|
deleteEvent: async (eventId: string): Promise<void> => {
|
||||||
|
await eventApiClient.delete(`/events/${eventId}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 이벤트 조기 종료
|
||||||
|
endEvent: async (eventId: string, reason: string): Promise<EventDetailResponse> => {
|
||||||
|
const response = await eventApiClient.post<EventDetailResponse>(`/events/${eventId}/end`, {
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default eventApi;
|
||||||
@ -1,2 +1,6 @@
|
|||||||
export { apiClient } from './client';
|
export { apiClient, participationClient } from './client';
|
||||||
export type { ApiError } from './types';
|
export type { ApiError } from './types';
|
||||||
|
export * from './contentApi';
|
||||||
|
export * from './aiApi';
|
||||||
|
export * from './eventApi';
|
||||||
|
export * from './participation.api';
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user