mirror of
https://github.com/ktds-dg0501/kt-event-marketing-fe.git
synced 2025-12-06 12:16:24 +00:00
- eventApi에 getAiRecommendations 메서드 추가 - Job COMPLETED 시 Event Service의 공개 API로 추천 결과 조회 - AI Service Internal API 대신 Event Service API 사용 - 타입 정의 통합 및 중복 제거 - 환경변수 포트 설정 수정 (AI_HOST: 8083) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
662 lines
24 KiB
TypeScript
662 lines
24 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } 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 { eventApi } from '@/shared/api';
|
||
import type { AiRecommendationResult, EventRecommendation } from '@/shared/api/eventApi';
|
||
|
||
// 디자인 시스템 색상
|
||
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 {
|
||
eventId?: string; // 이전 단계에서 생성된 eventId
|
||
objective?: string; // 이전 단계에서 선택된 objective
|
||
onNext: (data: {
|
||
recommendation: EventRecommendation;
|
||
eventId: string;
|
||
}) => void;
|
||
onBack: () => void;
|
||
}
|
||
|
||
// 쿠키에서 값 가져오기
|
||
const getCookie = (name: string): string | null => {
|
||
if (typeof document === 'undefined') return null;
|
||
|
||
const value = `; ${document.cookie}`;
|
||
const parts = value.split(`; ${name}=`);
|
||
if (parts.length === 2) {
|
||
return parts.pop()?.split(';').shift() || null;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
// 쿠키 저장 함수
|
||
const setCookie = (name: string, value: string, days: number = 1) => {
|
||
if (typeof document === 'undefined') return;
|
||
|
||
const expires = new Date();
|
||
expires.setTime(expires.getTime() + days * 24 * 60 * 60 * 1000);
|
||
document.cookie = `${name}=${value};expires=${expires.toUTCString()};path=/`;
|
||
};
|
||
|
||
export default function RecommendationStep({
|
||
eventId: initialEventId,
|
||
objective,
|
||
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 }>>({});
|
||
|
||
// 중복 호출 방지를 위한 ref
|
||
const requestedEventIdRef = useRef<string | null>(null);
|
||
|
||
// 컴포넌트 마운트 시 AI 추천 요청
|
||
useEffect(() => {
|
||
// props에서만 eventId를 받음 (쿠키 사용 안 함)
|
||
if (initialEventId) {
|
||
// 이미 요청한 eventId면 중복 요청하지 않음
|
||
if (requestedEventIdRef.current === initialEventId) {
|
||
console.log('⚠️ 이미 요청한 eventId입니다. 중복 요청 방지:', initialEventId);
|
||
return;
|
||
}
|
||
|
||
requestedEventIdRef.current = initialEventId;
|
||
setEventId(initialEventId);
|
||
console.log('✅ RecommendationStep - eventId 설정:', initialEventId);
|
||
// eventId가 있으면 바로 AI 추천 요청
|
||
requestAIRecommendations(initialEventId);
|
||
} else {
|
||
console.error('❌ eventId가 없습니다. ObjectiveStep으로 돌아가세요.');
|
||
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||
}
|
||
}, [initialEventId]);
|
||
|
||
const requestAIRecommendations = async (evtId: string) => {
|
||
try {
|
||
setLoading(true);
|
||
setError(null);
|
||
|
||
// 로그인한 사용자 정보에서 매장 정보 가져오기
|
||
const user = JSON.parse(localStorage.getItem('user') || '{}');
|
||
console.log('📋 localStorage user:', user);
|
||
|
||
// UUID v4 생성 함수 (테스트용)
|
||
const generateUUID = () => {
|
||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
|
||
const r = Math.random() * 16 | 0;
|
||
const v = c === 'x' ? r : (r & 0x3 | 0x8);
|
||
return v.toString(16);
|
||
});
|
||
};
|
||
|
||
// storeId: 로그인한 경우 user.storeId 사용, 아니면 테스트용 UUID 생성
|
||
const storeInfo = {
|
||
storeId: user.storeId ? String(user.storeId) : generateUUID(),
|
||
storeName: user.storeName || '테스트 매장',
|
||
category: user.industry || '음식점',
|
||
description: user.businessHours || '테스트 설명',
|
||
};
|
||
|
||
console.log('📤 전송할 storeInfo:', storeInfo);
|
||
console.log('🎯 eventId:', evtId);
|
||
console.log('🎯 objective:', objective || '테스트 목적');
|
||
|
||
// AI 추천 요청
|
||
const jobResponse = await eventApi.requestAiRecommendations(
|
||
evtId,
|
||
objective || '테스트 목적',
|
||
storeInfo
|
||
);
|
||
|
||
console.log('📦 백엔드 응답 (전체):', JSON.stringify(jobResponse, null, 2));
|
||
|
||
// 백엔드 응답 구조 확인: { success, data: { jobId, status, message }, timestamp }
|
||
const actualJobId = (jobResponse as any).data?.jobId || jobResponse.jobId;
|
||
|
||
console.log('📦 jobResponse.data?.jobId:', (jobResponse as any).data?.jobId);
|
||
console.log('📦 jobResponse.jobId:', jobResponse.jobId);
|
||
console.log('📦 실제 사용할 jobId:', actualJobId);
|
||
|
||
if (!actualJobId) {
|
||
console.error('❌ 백엔드에서 jobId를 반환하지 않았습니다!');
|
||
console.error('📦 응답 구조:', JSON.stringify(jobResponse, null, 2));
|
||
setError('백엔드에서 Job ID를 받지 못했습니다. 백엔드 응답을 확인해주세요.');
|
||
setLoading(false);
|
||
return;
|
||
}
|
||
|
||
// jobId를 쿠키에 저장
|
||
setCookie('jobId', actualJobId, 1);
|
||
setJobId(actualJobId);
|
||
console.log('✅ AI 추천 Job 생성 완료, jobId:', actualJobId);
|
||
console.log('🍪 jobId를 쿠키에 저장:', actualJobId);
|
||
|
||
// Job 폴링 시작 (2초 후 시작하여 백엔드에서 Job 저장 시간 확보)
|
||
setTimeout(() => {
|
||
pollJobStatus(actualJobId, evtId);
|
||
}, 2000);
|
||
} 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 {
|
||
// jobId 확인: 파라미터 우선, 없으면 쿠키에서 읽기
|
||
const currentJobId = jId || getCookie('jobId');
|
||
|
||
if (!currentJobId) {
|
||
console.error('❌ jobId를 찾을 수 없습니다 (파라미터와 쿠키 모두 없음)');
|
||
setError('jobId를 찾을 수 없습니다');
|
||
setLoading(false);
|
||
setPolling(false);
|
||
return;
|
||
}
|
||
|
||
console.log(`🔄 Job 상태 조회 시도 (${attempts + 1}/${maxAttempts}), jobId: ${currentJobId}`);
|
||
console.log(`🍪 jobId 출처: ${jId ? '파라미터' : '쿠키'}`);
|
||
|
||
const status = await eventApi.getJobStatus(currentJobId);
|
||
console.log('✅ Job 상태:', status);
|
||
|
||
if (status.status === 'COMPLETED') {
|
||
// AI 추천 결과 조회 (Event Service API 사용)
|
||
const recommendations = await eventApi.getAiRecommendations(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);
|
||
|
||
// Job을 찾을 수 없는 경우 (404 또는 JOB_001) - 초기 몇 번은 재시도
|
||
if (err.response?.data?.errorCode === 'JOB_001' || err.response?.status === 404) {
|
||
attempts++;
|
||
if (attempts < 5) { // 처음 5번 시도는 Job 생성 대기
|
||
console.log(`⏳ Job이 아직 준비되지 않음. ${attempts}/5 재시도 예정...`);
|
||
setTimeout(poll, 3000); // 3초 후 재시도
|
||
return;
|
||
} else if (attempts < maxAttempts) {
|
||
console.log(`⏳ Job 폴링 계속... ${attempts}/${maxAttempts}`);
|
||
setTimeout(poll, 5000); // 5초 후 재시도
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 다른 에러이거나 재시도 횟수 초과
|
||
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);
|
||
// props에서 eventId가 없으면 쿠키에서 읽어오기
|
||
const evtId = initialEventId || getCookie('eventId');
|
||
|
||
if (evtId) {
|
||
requestAIRecommendations(evtId);
|
||
} else {
|
||
setError('이벤트 ID가 없습니다. 이전 단계로 돌아가서 다시 시도하세요.');
|
||
}
|
||
}}
|
||
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>
|
||
);
|
||
}
|