merrycoral 4e4d9dd313 이벤트 생성 API 호출 오류 수정
- RecommendationStep: selectObjective 메서드 사용으로 수정
- Mock API: 응답 형식을 shared/api/eventApi에 맞춤
- 빌드 오류 해결 및 정상 동작 확인
2025-10-30 01:57:38 +09:00

570 lines
20 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Card,
CardContent,
Button,
Grid,
Chip,
TextField,
Radio,
RadioGroup,
FormControlLabel,
IconButton,
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 = {
pink: '#F472B6',
purple: '#C084FC',
purpleLight: '#E9D5FF',
blue: '#60A5FA',
mint: '#34D399',
orange: '#FB923C',
yellow: '#FBBF24',
gray: {
900: '#1A1A1A',
700: '#4A4A4A',
500: '#9E9E9E',
300: '#D9D9D9',
100: '#F5F5F5',
},
};
interface RecommendationStepProps {
objective?: EventObjective;
eventId?: string; // 이전 단계에서 생성된 eventId
onNext: (data: {
recommendation: EventRecommendation;
eventId: string;
}) => void;
onBack: () => void;
}
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 [aiResult, setAiResult] = useState<AIRecommendationResult | null>(null);
const [selected, setSelected] = useState<number | null>(null);
const [editedData, setEditedData] = useState<Record<number, { title: string; description: string }>>({});
// 컴포넌트 마운트 시 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 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,
[optionNumber]: {
...prev[optionNumber],
title
},
}));
};
const handleEditDescription = (optionNumber: number, description: string) => {
setEditedData((prev) => ({
...prev,
[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 } }}>
{/* Header */}
<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>
{/* Trends Analysis */}
<Card
elevation={0}
sx={{
mb: 10,
borderRadius: 4,
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
}}
>
<CardContent sx={{ p: 8 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 6 }}>
<Insights sx={{ fontSize: 32, color: colors.purple }} />
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.25rem' }}>
AI
</Typography>
</Box>
<Grid container spacing={6}>
<Grid item xs={12} md={4}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 2, 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>
{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>
{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>
{/* 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' }}>
. .
</Typography>
</Box>
{/* Recommendations */}
<RadioGroup value={selected} onChange={(e) => setSelected(Number(e.target.value))}>
<Grid container spacing={6} sx={{ mb: 10 }}>
{aiResult.recommendations.map((rec) => (
<Grid item xs={12} key={rec.optionNumber}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
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.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.optionNumber)}
>
<CardContent sx={{ p: 6 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 4 }}>
<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 }}
/>
</Box>
<TextField
fullWidth
variant="outlined"
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: '1.1rem', fontWeight: 600, py: 2 },
}}
/>
<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: 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.targetAudience}
</Typography>
</Grid>
<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.min / 10000).toFixed(0)}~{(rec.estimatedCost.max / 10000).toFixed(0)}
</Typography>
</Grid>
<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.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>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</RadioGroup>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 4 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onBack}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
disabled={selected === null || loading}
onClick={handleNext}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 700,
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
'&:hover': {
background: `linear-gradient(135deg, ${colors.purple} 0%, ${colors.pink} 100%)`,
opacity: 0.9,
},
'&:disabled': {
background: colors.gray[300],
},
}}
>
{loading ? <CircularProgress size={24} sx={{ color: 'white' }} /> : '다음'}
</Button>
</Box>
</Container>
</Box>
);
}