cherry2250 6331ab3fde Analytics API 프록시 라우트 구현 및 CORS 오류 해결
- Next.js API 프록시 라우트 8개 생성 (User/Event Analytics)
- analyticsClient baseURL을 프록시 경로로 변경
- analyticsApi 경로에서 /api/v1 접두사 제거
- 404/400 에러에 대한 사용자 친화적 에러 처리 추가
- Dashboard, Event Detail, Analytics 페이지 에러 핸들링 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-31 00:34:20 +09:00

743 lines
25 KiB
TypeScript

import { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Card,
CardContent,
Button,
Radio,
RadioGroup,
FormControlLabel,
IconButton,
Dialog,
Grid,
Alert,
} from '@mui/material';
import { ArrowBack, ZoomIn, Psychology, Refresh } from '@mui/icons-material';
import { contentApi, ImageInfo } from '@/shared/api/contentApi';
import Image from 'next/image';
// 디자인 시스템 색상
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 ImageStyle {
id: 'SIMPLE' | 'FANCY' | 'TRENDY';
name: string;
gradient?: string;
icon: string;
textColor?: string;
}
const imageStyles: ImageStyle[] = [
{
id: 'SIMPLE',
name: '스타일 1: 심플',
icon: 'celebration',
},
{
id: 'FANCY',
name: '스타일 2: 화려',
gradient: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
icon: 'auto_awesome',
textColor: 'white',
},
{
id: 'TRENDY',
name: '스타일 3: 트렌디',
gradient: 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
icon: 'trending_up',
textColor: 'white',
},
];
interface ContentPreviewStepProps {
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({
eventId: propsEventId,
eventTitle: propsEventTitle,
eventDescription: propsEventDescription,
onNext,
onSkip,
onBack,
}: ContentPreviewStepProps) {
const [loading, setLoading] = useState(true);
const [selectedStyle, setSelectedStyle] = useState<'SIMPLE' | 'FANCY' | 'TRENDY' | null>(null);
const [fullscreenOpen, setFullscreenOpen] = useState(false);
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(() => {
// 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);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [propsEventId, propsEventTitle, propsEventDescription]);
const loadImages = async (data: EventCreationData): Promise<boolean> => {
try {
setError(null);
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 = (image: ImageInfo, e: React.MouseEvent) => {
e.stopPropagation();
setFullscreenImage(image);
setFullscreenOpen(true);
};
const handleNext = () => {
if (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 }}>
<Container maxWidth="lg" sx={{ pt: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<IconButton onClick={onBack}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
SNS
</Typography>
</Box>
<Box sx={{ textAlign: 'center', mt: 15, mb: 15, maxWidth: 600, mx: 'auto' }}>
{/* 그라데이션 스피너 */}
<Box
sx={{
width: 80,
height: 80,
margin: '0 auto 32px',
borderRadius: '50%',
background: `conic-gradient(from 0deg, ${colors.purple}, ${colors.pink}, ${colors.blue}, ${colors.purple})`,
animation: 'spin 1.5s linear infinite',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
'@keyframes spin': {
'0%': { transform: 'rotate(0deg)' },
'100%': { transform: 'rotate(360deg)' },
},
'&::before': {
content: '""',
position: 'absolute',
width: 60,
height: 60,
borderRadius: '50%',
backgroundColor: 'background.default',
},
}}
>
<Psychology
sx={{
fontSize: 40,
color: colors.purple,
zIndex: 1,
animation: 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': {
'0%, 100%': { opacity: 1, transform: 'scale(1)' },
'50%': { opacity: 0.7, transform: 'scale(0.95)' },
},
}}
/>
</Box>
{/* 진행률 바 */}
<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' }}>
{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>
);
}
return (
<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: { xs: 4, sm: 8 }, pb: { xs: 4, sm: 8 }, px: { xs: 2, sm: 6, md: 8 } }}>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 3, mb: { xs: 3, sm: 8 } }}>
<IconButton onClick={onBack}>
<ArrowBack />
</IconButton>
<Typography variant="h5" sx={{ fontWeight: 700, fontSize: '1.5rem' }}>
SNS
</Typography>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: { xs: 3, sm: 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: { xs: 3, sm: 8 }, textAlign: 'center', fontSize: '1rem' }}>
</Typography>
<RadioGroup value={selectedStyle} onChange={(e) => handleStyleSelect(e.target.value as 'SIMPLE' | 'FANCY' | 'TRENDY')}>
<Grid container spacing={6} sx={{ mb: { xs: 4, sm: 10 } }}>
{imageStyles.map((style) => (
<Grid item xs={12} md={4} key={style.id}>
<Card
elevation={0}
sx={{
cursor: 'pointer',
borderRadius: 4,
border: selectedStyle === style.id ? 2 : 1,
borderColor: selectedStyle === style.id ? colors.purple : 'divider',
bgcolor: selectedStyle === style.id ? `${colors.purpleLight}40` : 'background.paper',
transition: 'all 0.3s',
position: 'relative',
boxShadow: selectedStyle === style.id ? '0 4px 12px rgba(0, 0, 0, 0.15)' : '0 2px 8px rgba(0, 0, 0, 0.08)',
'&:hover': {
borderColor: colors.purple,
transform: 'translateY(-2px)',
boxShadow: '0 8px 16px rgba(0, 0, 0, 0.15)',
},
}}
onClick={() => handleStyleSelect(style.id)}
>
<CardContent sx={{ p: 0 }}>
{/* 스타일 이름 */}
<Box sx={{ p: 4, borderBottom: 1, borderColor: 'divider', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Typography variant="h6" sx={{ fontWeight: 700, fontSize: '1.125rem' }}>
{style.name}
</Typography>
<FormControlLabel
value={style.id}
control={<Radio sx={{ color: colors.purple, '&.Mui-checked': { color: colors.purple } }} />}
label=""
sx={{ m: 0 }}
/>
</Box>
{/* 이미지 프리뷰 */}
<Box
sx={{
width: '100%',
aspectRatio: '1 / 1',
position: 'relative',
overflow: 'hidden',
bgcolor: colors.gray[100],
}}
>
{(() => {
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>
{/* 크게보기 버튼 */}
<Box sx={{ p: 4, display: 'flex', justifyContent: 'center' }}>
<Button
variant="outlined"
startIcon={<ZoomIn />}
onClick={(e) => {
const image = generatedImages.get(style.id);
if (image) {
handlePreview(image, e);
}
}}
disabled={!generatedImages.has(style.id)}
sx={{
borderRadius: 2,
py: 1.5,
px: 4,
fontSize: '0.875rem',
fontWeight: 600,
}}
>
</Button>
</Box>
</CardContent>
</Card>
</Grid>
))}
</Grid>
</RadioGroup>
{/* Action Buttons */}
<Box sx={{ display: 'flex', gap: 4 }}>
<Button
fullWidth
variant="outlined"
size="large"
onClick={onSkip}
sx={{
py: 3,
borderRadius: 3,
fontSize: '1rem',
fontWeight: 600,
borderWidth: 2,
'&:hover': {
borderWidth: 2,
},
}}
>
</Button>
<Button
fullWidth
variant="contained"
size="large"
disabled={!selectedStyle}
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],
},
}}
>
</Button>
</Box>
</Container>
{/* Fullscreen Dialog */}
<Dialog
open={fullscreenOpen}
onClose={() => setFullscreenOpen(false)}
maxWidth={false}
PaperProps={{
sx: {
bgcolor: 'rgba(0, 0, 0, 0.95)',
boxShadow: 'none',
maxWidth: '90vw',
maxHeight: '90vh',
},
}}
>
<Box
sx={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
p: 4,
position: 'relative',
}}
>
<IconButton
onClick={() => setFullscreenOpen(false)}
sx={{
position: 'absolute',
top: 16,
right: 16,
bgcolor: 'rgba(255, 255, 255, 0.9)',
'&:hover': { bgcolor: 'white' },
}}
>
<span className="material-icons">close</span>
</IconButton>
{fullscreenImage && (
<Box
sx={{
width: '100%',
maxWidth: 800,
aspectRatio: '1 / 1',
position: 'relative',
borderRadius: 4,
overflow: 'hidden',
}}
>
<Image
src={fullscreenImage.cdnUrl}
alt={`${fullscreenImage.style} style`}
fill
style={{ objectFit: 'contain' }}
unoptimized
/>
</Box>
)}
</Box>
</Dialog>
</Box>
);
}